tidycjk 0.2.0
A release about the gap between what the package could do and what it said it could do. 0.1.0 shipped with no bundled word segmenter, no romanisation and no script conversion, on the reasoning that each needed a dictionary the package could not carry. That was half right. The dictionaries were already installed: ICU carries them, stringi carries ICU, and stringi has been a hard dependency since the first commit. Most of what follows is that discovery being spent.
Four vignettes are new, and there were none before.
Word segmentation now ships
-
engine = "icu"is a real word segmenter.cjk_segment("我今天很開心", engine = "icu")returns我/今天/很/開心, words rather than characters, using ICU’s dictionary-based break iterators for Chinese and Japanese. It costs no new dependency. - Anything in
...reaches the engine,localeincluded, though see “How the new verbs behave” below, becauselocaledoes not do what its name suggests here. -
enginestill has no default, for the reason it always had:"character"answers a different question from the one a caller asking for words is asking, and would otherwise be the answer they got by accident. - This reverses a claim 0.1.0 made. That release said no word segmenter could be bundled because jiebaR had been archived from CRAN. The archival was true and still is; the error was concluding from it that there was no bundled option, when one had been linked into a hard dependency the whole time.
-
cjk_tokens()gainsoutput, naming the token column. The default is still"token", and an existing column of that name is still replaced, but a caller whose data already has atokencolumn worth keeping now has somewhere else to put the tokens, the escape hatch tidytext’sunnest_tokens()provides. It follows..., so it has to be given by its full name. -
A segmentation engine’s element types are checked, not only the list and its length.
as.character()deparses a list rather than coercing it, so an engine returninglist(c(1, 2))used to yield the single token"c(1, 2)". An atomic element is still coerced, so integers and factors work as before.
Transliteration
-
cjk_romanize()romanises Han, kana and Hangul to the Latin alphabet.ascii = TRUEdrops tone marks, which is what you want for a sort key. -
cjk_simplify()andcjk_traditionalize()convert between simplified and traditional Han. -
to_hiragana()andto_katakana()convert between the kana syllabaries. Halfwidth input is handled and composed on the way through:to_katakana("ガ")is the single characterガ. -
cjk_jamo()andcjk_compose_jamo()decompose Hangul syllables into jamo and put them back, by the syllable arithmetic in the Unicode standard rather than by normalisation. The round trip is exact, and text that is not Hangul passes through untouched: an accented letter keeps its accent, and a compatibility ideograph, which NFD and NFC would both replace with its unified form, stays as it is.
Two of these are approximations, and say so
0.1.0’s “Not in this release” section withheld traditional/simplified conversion because character-level conversion “is wrong often enough to matter”. That is true of a character table, and ICU’s transform is not one. The limits that remain are on the help pages and in vignette("transliteration"):
- Conversion is context-aware on characters and blind to vocabulary. The one-to-many cases resolve from context:
头发gives頭髮and发送gives發送,后天gives後天while皇后stays皇后, and all twenty such words checked for this release came out right. What it does not do is substitute regional vocabulary:软件becomes軟件, not Taiwan’s軟體. OpenCC and its regional configurations are the answer there. - Romanisation reads each Han character alone, and as Chinese. A character with two readings gets its commoner one whatever the word, so
银行(“bank”) comes backyín xíngrather thanyín háng; and Japanese kanji come back in pinyin,日本語givingrì běn yǔrather thannihongo. Use it for a sort or search key, and a morphological analyser such as gibasa for Japanese readings.
Sentences and n-grams
-
cjk_sentences()splits text into sentences with ICU’s sentence break iterator, which knows U+3002, U+FF01 and U+FF1F. A regular expression on[.!?]finds no boundary at all in Chinese or Japanese, which is the reason this exists. The spans are returned unmodified, so the pieces concatenate back to the input exactly; whitespace between two sentences stays attached to the first rather than being trimmed away. -
cjk_ngrams()returns every run ofnconsecutive characters. Character n-grams are the standard 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, and they degrade gracefully on the names and coinages where a segmenter is least reliable. No gram is formed across whitespace.
Cleaning
-
cjk_strip_punct()removes punctuation by Unicode category, not by a POSIX class. The usual spelling is unreliable on CJK twice over.gsub("[[:punct:]]", "", x)is resolved through the C library, so it removes nothing underLC_ALL=Cand everything under a UTF-8 locale: the same script, two answers, no warning.perl = TRUElooks safer and is worse, PCRE’s POSIX classes being ASCII-only unless(*UCP)is set, so it silently removes no CJK punctuation in any locale at all. - It keeps U+30FC. The katakana-hiragana prolonged sound mark looks like a dash and is a modifier letter, carrying the long vowel in most Japanese loanwords. Testing the General_Category keeps it; any rule phrased about dashes removes it and quietly changes the words for coffee and ramen into something else.
-
replacementdefaults to a space rather than"". Deleting a full stop closes the gap, and the characters that flanked it become adjacent, socjk_ngrams()then reports a bigram spanning a sentence boundary, a word that was never written. A space keeps the boundary, and every verb here that walks a string already declines to cross whitespace. -
symbols = TRUEadditionally removes General_CategoryS: the fullwidth tilde, currency signs and mathematical operators. It is off by default because a currency sign is often content.
Ordering
-
cjk_sort()andcjk_order()sort CJK text by an ICU collation.sort()on Chinese gives code point order, which is a real order and the wrong one: the unified ideographs are laid out in KangXi radical-stroke order, so sorting by code point sorts by radical within the base block. It is not phonetic, which is what sorting a column of names calls for, and it is not even radical order across blocks, since every Extension A character sorts ahead of every base-block one.locale = "zh"sorts Han by pinyin;"zh-u-co-stroke"by stroke count. - Both add two guards over calling stringi directly.
NAis kept and sorts last, wherestri_sort()drops it and silently shortens a column. And a locale whose language ICU has no collation for is an error, where stringi warns and falls back to the root collation, a plausible-looking wrong order. An unrecognised region is not an error:"zh-CH"resolves to"zh"and still sorts by pinyin.
Normalisation
-
cjk_normalize()applies the Unicode normalisation forms ("nfc","nfd","nfkc","nfkd"and"nfkc_casefold") so that strings which look the same compare the same. The package had been telling callers to “normalise first” without giving them a way to do it. -
Plain NFC is not a no-op on Han, and the help page says so. Compatibility ideographs have singleton canonical mappings, so
"nfc"rewrites them exactly as"nfkc"does: 460 of the 472 characters in the CJK Compatibility Ideographs block are folded away, along with all 542 of the Supplement block. The 12 survivors are listed. If that distinction carries meaning in your data, as it can in Korean and Japanese name records, keep the original column, because no form preserves it. -
drop_variation_selectors = TRUEremoves them first. Before, not after: a variation selector has combining class zero and blocks canonical composition across itself, so stripping one after normalising can leave text that is no longer in the form just requested.AU+FE00 U+0300 normalises to itself under NFC and to U+00C0 once the selector is gone. -
"nfkc_casefold"deletes everyDefault_Ignorablecode point, which the other four forms keep: the variation selectors, the zero-width joiner and non-joiner, the zero-width space, the soft hyphen, the tag characters and U+FEFF. So a byte-order mark survives the other four and not this one. It is documented rather than worked around, because it is what the form is defined to do; every other verb in the package preserves a leading U+FEFF. -
to_halfwidth()andto_fullwidth()are unchanged and remain the narrow alternative: they move text along the width axis and touch nothing else.
Layout
-
cjk_wrap()wraps text to a width in terminal columns, completing the set withcjk_pad()andcjk_truncate(). Break positions come from ICU’s implementation of the Unicode line breaking algorithm (Annex #14), so a line never begins with。or)and an opening bracket never ends one, none of which splitting every n columns would respect. Lines are filled greedily and come back joined by\n, so the result is the same length asx.indentandexdentare supported.
Messages and validation
-
Undecodable text is one error from every verb. Left to stringi, the same bytes were reported five different ways depending on which entry point a verb reached first: our message from the code-point verbs, U+FFFD replacement characters from the ICU transforms, the raw bytes handed back by
cjk_sentences()andcjk_sort(), a bare “invalid multibyte string” fromcjk_wrap(), and the bad byte silently dropped by the"icu"engine. Every verb now stops with “xmust be valid UTF-8” before doing any work, naming the argument (colin the tidy verbs, andpad,ellipsisandreplacementfor the arguments that carry text of their own). A string marked"bytes"is refused the same way. -
An enumerated argument is rejected by name.
match.arg()names a variable the caller never wrote: a value that is not character becomes “‘arg’ must be NULL or a character vector”, and a string matching no choice “‘arg’ should be one of”, both withmatch.arg()’s own call attached.cjk_pad(side = )andcjk_normalize(form = )now say`side` must be one of "right", "left", "both", listing the choices. Every valuematch.arg()accepted before is still accepted, including partial matching andNULLmeaning the first choice. -
A bad
localeis an error whatever the input. The check used to run inside the call to stringi, socjk_sort(character(0), locale = "xx")andcjk_wrap(NA, 5, locale = "xx")passed. It now runs first. The language is matched without regard to case, as BCP 47 specifies, so"ZH"and"Zh-Hant"are accepted;"root"and"und"ask for ICU’s root locale by name; and""means the session default, as it does to stringi. -
cjk_wrap()refuses anindentorexdentpast the integer range by name, rather than handing stringi anNAit reports in its own terms. -
cjk_pad(width = )no longer warns before its error on a closure or an environment. The check has to callis.na()so that an all-NAlogical width can pass and propagate, andis.na()on a function warns; testingis.atomic()first keeps both properties. -
Every error the package constructs carries no call, so a message reads
Error: `n` must be a single positive whole number.rather than repeating the whole call back at the reader. A test walks the installed namespace to keep it that way, and checks that the three handlers which re-raise someone else’s condition leave its call and message untouched.
Documentation
-
?tidycjkis brought up to date. Its contract section lists every verb rather than the 0.1.0 ones. Its “Input encoding” section gives working ways to read a legacy file,read.csv(f, fileEncoding = "GBK")andreadLines(file(f, encoding = "Shift_JIS")); theencodingargument of those functions only labels strings, andread.csv(f, encoding = "GBK")fails outright in a UTF-8 session. And its related packages are ones you can install: 0.1.0 pointed at pinyin, tmcn, zipangu and Nippon, all four of which are archived from CRAN. -
An example comment in
?cjk_truncatecontradicted its own output. “six columns is three ideographs” sat above a call returning one ideograph, because the ellipsis is counted against the budget and costs three of the six columns. It now says what the output shows, and a third call withellipsis = ""makes the original point properly. -
Example blocks say what their strings are. CJK literals in examples are written as
\uXXXXescapes so that no CJK glyph reaches the LaTeX manual, and?cjk_ratio,?cjk_summary,?cjk_char_countsand?cjk_tokenshad no comment saying what those escapes spell. - A hex logo, and a pkgdown site with a theme, a light/dark switch and a Gallery article that walks every verb over real text.
Fixes to 0.1.0
Seven things in this release are fixes in the sense a 0.1.0 user cares about. The rest of what changed during development concerned code that had never shipped, and is described under the features it belongs to rather than dressed up as a fix.
-
A list argument was measured as the R code that builds it. Every verb starts by coercing
x, which is what lets a numeric or a factor column through. Butas.character()does not coerce a list, it deparses it, socjk_width(list(c("a", "b")))answered 11, the display width of the eleven-character stringc("a", "b"), with no error and no warning. Every exported verb had this hole, and the list a caller has in hand is usually another verb’s output. All of them now raise, naming the two ways out, and the three tidy verbs check a pulled list-column the same way.NULLis still accepted, and still givescharacter(0):is.atomic(NULL)wasTRUEuntil R 4.4.0 and isFALSEafter it, so the guard admits it explicitly rather than inheriting a contract that changed under it. -
Korean and Chinese text containing the katakana middle dot was called Japanese.
cjk_detect_language()takes any kana as proof of Japanese, and U+30FB sits in the Katakana block, but it is punctuation that Chinese and Korean text use too, to separate the parts of a transliterated name. So"서울・부산"(“Seoul / Busan”) came back"japanese". The dot, and its halfwidth form U+FF65, no longer count as kana. -
Real letters of the covered scripts were not CJK.
?cjk_blockssaid every phonetic script was covered in full, extension blocks included, and four kana blocks were missing: Kana Supplement, Kana Extended-A, Kana Extended-B and Small Kana Extension, which hold the hentaigana, the archaic and small kana and the Minnan tone letters. Extension J, the 4,298 ideographs Unicode 17.0 added, was missing too.has_cjk()answeredFALSEfor all of them. The table is now current to Unicode 18.0, and the kana blocks are split where Unicode’s Script property changes, so each letter is labelled hiragana or katakana as it should be. -
cjk_detect_language(han_only = )no longer turns two malformed values into languages.is.na(NaN)isTRUE, soNaNwas returned as the language"NaN"; andis.na(list(NA))isTRUEwhileas.character(list(NA))is the string"NA", not a missing value, so a downstreamis.na()would have called it a real answer. Both are now errors. -
cjk_segment()andcjk_tokens()no longer delete a byte-order mark.stri_split_charclass()reads a leading U+FEFF in its own input as a byte-order mark and drops it, andomit_empty = TRUEthen discards the emptied piece, so a mark beginning a non-CJK run disappeared from the"character"engine’s output. U+FEFF was the only zero-width character this happened to. Both engines now count the leading run off and restore it, as the other verbs already did. -
An unknown engine is an error even for empty input. The engine was looked up after the zero-length exit, so
cjk_segment(character(0), engine = "nope")and a zero-rowcjk_tokens()returned quietly. An engine you register yourself is still not called on an empty vector. -
Every published URL now points at
tidycjk. The repository had been created astidyckj, with the last two letters transposed, and the typo reached theURLandBugReportsfields, the pkgdown site, the R-CMD-check badge and thepak::pak()install line. GitHub redirects the repository addresses, so the ones in the 0.1.0 tarball still resolve; it does not redirect a project Pages site, so the old documentation URL 404s and this release is what corrects it on CRAN.
How the new verbs behave
Not fixes, since these verbs are new, but the details most likely to surprise you, gathered in one place.
-
A leading byte-order mark survives.
stri_sort(),stri_split_boundaries()andstri_wrap()all read a leading U+FEFF as a byte-order mark and drop it.cjk_sort(),cjk_sentences()andcjk_wrap()hide the whole leading run from stringi and restore it, and the jamo verbs work on code points, so a mark from an Excel-written CSV is not silently deleted. A U+FEFF elsewhere in a string may still be lost tocjk_wrap(), because re-flowing can put it at the start of a segment;?cjk_wrapsays so. -
cjk_sort()isx[cjk_order(x)], so it can only reorder its input. A sort must not rewrite the values it was handed, and building it onstri_sort(), which returns round-tripped values, would let it. -
cjk_wrap()fills lines greedily, as a terminal does. stringi’s own default is an optimal fit that evens out line lengths, and it is not used: its cost grows far faster than the text (40,000 characters of CJK exhaust 1.5 GB of memory), and appending text can re-flow every line above it, which it did in 424 of 2,000 random trials where the greedy fill never does. -
cjk_wrap()re-flows rather than measures. Existing newlines are whitespace to the algorithm and are replaced by the new breaks, so"a\nb"wrapped wide comes back as"a b". A string of nothing but whitespace re-flows to"", wherecjk_pad()would have kept it. -
cjk_wrap()returns NFC, which no other layout verb does. ICU’s line-breaking works on normalised text, sostri_wrap()normalises and this verb inherits it:cjk_wrap("豈", 2)is U+8C48. A sweep of every code point in Unicode puts the affected set at 1,120: 460 of the 472 assigned CJK Compatibility Ideographs, all 542 of the supplement, 34 Hebrew presentation forms, 13 musical symbols, and 71 scattered singletons and composition exclusions such as the Kelvin and Ohm signs, Greek letters with an oxia and Devanagari letters with a nukta. That is exactly the set NFC changes, no more and no fewer: each is a duplicate of a character or sequence Unicode prefers, kept for compatibility. Everyday text, precomposed Latin and Hangul syllables included, is already NFC and passes through untouched.normalize = FALSEwould stop it, but that argument also turns off whitespace collapsing and makesstri_wrap()raise on any string containing a newline, so it is documented and pinned by a test rather than changed. Callcjk_normalize()first if you want the normalisation to be explicit. -
Kana conversion is a normalisation, not a reversible mapping. On text holding both syllabaries it erases the distinction between them, and that distinction carries meaning: katakana marks loanwords and emphasis. ICU also maps the small katakana U+30F5 and U+30F6 to full-size hiragana and spells out the digraphs U+30FF and U+309F, so even all-katakana text does not always survive
to_katakana(to_hiragana(x)). -
The two segmentation engines do not tokenise punctuation alike.
"icu"drops punctuation and symbols along with whitespace;"character"keeps CJK punctuation as tokens, because those code points are in blockscjk_blocks()lists. The same sentence therefore yields different token counts, and the gap is punctuation rather than a disagreement about where words end. An emoji goes the same way. -
A locale ICU has no data for is an error, not a silent fallback. ICU resolves an unknown locale to the root one, and stringi reports that with a warning most callers never see, or, on stringi 1.6.2, with no warning at all. Every verb taking a
locale, and the"icu"engine, shares one guard, which checks the language subtag againststringi::stri_locale_list()rather than watching for a warning that is not dependable. An unrecognised region still resolves to its language, so"zh-CH"and"zh-u-co-stroke"are valid. -
localedoes not change CJK word or sentence boundaries. It is accepted and forwarded, but the CJK dictionary is chosen by the script of the text: seven Chinese, Japanese and Korean strings segment and split identically under"zh","ja","ko","en"and the session default. It does select the Annex #14 line-breaking style incjk_wrap(), which is why that verb takes one.
Notes
-
The tests assert laws over the code point space, not only examples.
test-invariants.Rchecks, over every code point in the block table, that each normal form is a fixed point of itself, thatnfc(nfd(x))isnfc(x)and likewise for NFKC, that the width and kana conversions are idempotent, that fullwidth text survives a trip to halfwidth and back, that the jamo round trip is the identity, that the layout verbs leave text that already fits alone, and that every transform returns one valid UTF-8 string per input. All 11,172 Hangul syllables decompose into 2 or 3 of 67 conjoining jamo and recompose exactly. The exhaustive pass isskip_on_cran(); a sampled pass and every block boundary run everywhere. - A sweep of all 1,112,029 valid code points confirms that
has_cjk(),cjk_ratio()andcjk_script()agree with the block table in both directions, no character wrongly called CJK and none wrongly left out, and thatcjk_width()is never missing and never outside 0:2. -
RoxygenNotereplacesConfig/roxygen2/version:man/is generated by roxygen2 7.3.2, and the file now records what actually built it. - Still no compiled code, no bundled data, and no network requests.
Still not in this release
-
Stroke counts, radicals, and readings other than ICU’s Mandarin. These need the Unihan database;
data-raw/unihan.Rdownloads and parses it, but nothing is bundled yet. - Vertical text layout. Unicode Annex #50 assigns each character an orientation for vertical writing. Nothing here models it.
-
Encoding detection.
stringi::stri_enc_detect()exists and is unreliable on short CJK samples, which is exactly when it would be used. It is not wrapped rather than wrapped badly. -
cjk_compose_jamo()taking the listcjk_jamo()returns. It would make the round trip compose directly instead of needing thevapply()step, and the case for it is real: that boilerplate sits in the middle of this package’s own worked example. Declined because it would reopen, for one function, exactly the hole closed everywhere else this release:cjk_sentences()andcjk_segment()also return lists, andcjk_compose_jamo(cjk_sentences(x))would then quietly paste sentences together instead of saying no. One function that guesses is worse than one line of boilerplate, and the error now names the line to write.
tidycjk 0.1.0
CRAN release: 2026-09-09
First release.
tidycjk is a tidy toolkit for Chinese, Japanese and Korean text: script and language classification, display width, width normalisation, and a pluggable word-segmentation engine that the caller names, as verbs that return tibbles. The package itself has no compiled code, bundles no data, and makes no network requests.
Tidy layer
-
cjk_summary(data, col)reportsn_docs,n_with_cjk,prop_with_cjkandmean_ratiofor a text column. -
cjk_char_counts(data, col)returns one row per distinct CJK character with its code point, script, Unicode block and count.
Segmentation
-
cjk_segment(x, engine)splits CJK text into words, andcjk_tokens(data, col, engine)is the tidy version, returning one row per token. - The engine is pluggable and required.
cjk_segmenters()lists what is available andregister_cjk_segmenter()adds an engine: any function of(x, ...)returning a list of character vectors. - No word segmenter is bundled, and
enginehas no default. jiebaR was the obvious candidate and was archived from CRAN on 2025-05-01, so it cannot be a dependency of a CRAN package.?cjk_segmentersshows the four lines that register it once you have installed it from source. - The one engine that ships is
"character": one token per CJK character, with runs of non-CJK text split on whitespace. It is character tokenisation rather than word segmentation and says so on its help page. Making it the silent default would have handed character tokens to callers asking for words, which is the mistake this package exists to avoid, so the choice is explicit instead.
Vector layer
-
has_cjk(),cjk_script()andcjk_ratio()classify and measure. -
cjk_detect_language()infers the language from the scripts present. -
cjk_width(),cjk_pad()andcjk_truncate()work in terminal columns rather than characters. -
to_halfwidth()andto_fullwidth()normalise width variants. -
cjk_blocks()exports the Unicode block table the package is built on, covering every unified ideograph block through Extension I as well as the phonetic scripts, CJK punctuation and the width variants.
Notes on the design
Language detection returns
NArather than guessing. Japanese written without kana is not distinguishable from Chinese by script alone, so a Han-only string getsNA.cjk_detect_language(x, han_only = "chinese")opts into the guess and keeps the assumption visible in the calling code.Width is delegated to stringi.
cjk_width()andcjk_pad()wrapstringi::stri_width()andstringi::stri_pad(), which read the live Unicode tables in ICU, the Unicode Consortium’s C library. A hand-maintained range table would go stale at every Unicode release, and the version commonly copied around is already wrong for tens of thousands of assigned code points: it stops below the supplementary planes, so every ideograph in Extensions B through I comes out one column instead of two.cjk_truncate()has nostringiequivalent and is implemented here.Normalisation is surgical.
to_halfwidth()maps fullwidth ASCII, the ideographic space and halfwidth katakana, and touches nothing else.NFKCadditionally rewrites ligatures, superscripts, Roman numerals, circled numbers, the no-break space and the CJK compatibility ideographs, which is almost never wanted.Voiced marks are composed by default. Halfwidth katakana writes a voiced syllable as two code points;
compose = TRUEfolds them into the single precomposed character, so"ガ"becomes"ガ". Every pair in the composition table agrees with Unicode NFC, including the five that break the base-plus-one rule.Ties never depend on the locale.
cjk_script()andcjk_char_counts()break ties by first appearance rather than by collation order.
Not in this release
-
Pinyin, stroke counts and radicals. These need the Unihan database.
data-raw/unihan.Rdownloads and parses it, but no character data is hand-written or bundled yet. pinyin and hanyupinyin are on CRAN today. - Traditional/simplified conversion. The Unihan database gives character-level mappings only, and character-level conversion is wrong often enough to matter: one simplified character can map to several traditional ones and the right choice is context-dependent. Shipping it as if it were complete would be a disservice. tmcn offers character-level conversion today; OpenCC is the phrase-level answer outside R.
