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.
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.... reaches the engine, locale included, though see "How
the new verbs behave" below, because locale does not do what its name
suggests here.engine still 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.cjk_tokens() gains output, 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 a token column worth keeping now has
somewhere else to put the tokens, the escape hatch
tidytext's unnest_tokens()
provides. It follows ..., so it has to be given by its full name.as.character() deparses a list rather than coercing it,
so an engine returning list(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.cjk_romanize() romanises Han, kana and Hangul to the Latin alphabet.
ascii = TRUE drops tone marks, which is what you want for a sort key.cjk_simplify() and cjk_traditionalize() convert between simplified
and traditional Han.to_hiragana() and to_katakana() convert between the kana
syllabaries. Halfwidth input is handled and composed on the way through:
to_katakana("ガ") is the single character ガ.cjk_jamo() and cjk_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.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"):
头发 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.银行 ("bank") comes back yín xíng rather than yín háng; and Japanese
kanji come back in pinyin, 日本語 giving rì běn yǔ rather than
nihongo. Use it for a sort or search key, and a morphological analyser
such as gibasa for Japanese
readings.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 of n consecutive 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.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 under LC_ALL=C and everything under a UTF-8 locale: the
same script, two answers, no warning. perl = TRUE looks 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.replacement defaults to a space rather than "". Deleting a full
stop closes the gap, and the characters that flanked it become adjacent, so
cjk_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 = TRUE additionally removes General_Category S: the fullwidth
tilde, currency signs and mathematical operators. It is off by default
because a currency sign is often content.cjk_sort() and cjk_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.NA is kept and sorts
last, where stri_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.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."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 = TRUE removes 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. A U+FE00 U+0300
normalises to itself under NFC and to U+00C0 once the selector is gone."nfkc_casefold" deletes every Default_Ignorable code 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() and to_fullwidth() are unchanged and remain the narrow
alternative: they move text along the width axis and touch nothing else.cjk_wrap() wraps text to a width in terminal columns, completing the
set with cjk_pad() and cjk_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 as
x. indent and exdent are supported.cjk_sentences() and cjk_sort(), a bare "invalid multibyte string"
from cjk_wrap(), and the bad byte silently dropped by the "icu" engine.
Every verb now stops with "x must be valid UTF-8" before doing any work,
naming the argument (col in the tidy verbs, and pad, ellipsis and
replacement for the arguments that carry text of their own). A string
marked "bytes" is refused the same way.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 with match.arg()'s own call
attached. cjk_pad(side = ) and cjk_normalize(form = ) now say
`side` must be one of "right", "left", "both", listing the choices.
Every value match.arg() accepted before is still accepted, including
partial matching and NULL meaning the first choice.locale is an error whatever the input. The check used to run
inside the call to stringi, so cjk_sort(character(0), locale = "xx") and
cjk_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 an indent or exdent past the integer range by
name, rather than handing stringi an NA it reports in its own terms.cjk_pad(width = ) no longer warns before its error on a closure or
an environment. The check has to call is.na() so that an all-NA logical
width can pass and propagate, and is.na() on a function warns; testing
is.atomic() first keeps both properties.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.?tidycjk is 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") and
readLines(file(f, encoding = "Shift_JIS")); the encoding argument of
those functions only labels strings, and read.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.?cjk_truncate contradicted 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 with ellipsis = "" makes the original point properly.\uXXXX escapes so that no CJK glyph reaches the LaTeX
manual, and ?cjk_ratio, ?cjk_summary, ?cjk_char_counts and
?cjk_tokens had no comment saying what those escapes spell.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.
x, which is what lets a numeric or a factor column
through. But as.character() does not coerce a list, it deparses it, so
cjk_width(list(c("a", "b"))) answered 11, the display width of the
eleven-character string c("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. NULL is
still accepted, and still gives character(0): is.atomic(NULL) was
TRUE until R 4.4.0 and is FALSE after it, so the guard admits it
explicitly rather than inheriting a contract that changed under it.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.?cjk_blocks said
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() answered
FALSE for 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) is TRUE, so NaN was returned as the
language "NaN"; and is.na(list(NA)) is TRUE while
as.character(list(NA)) is the string "NA", not a missing value, so a
downstream is.na() would have called it a real answer. Both are now
errors.cjk_segment() and cjk_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, and omit_empty = TRUE then 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.cjk_segment(character(0), engine = "nope") and a zero-row cjk_tokens() returned quietly. An engine
you register yourself is still not called on an empty vector.tidycjk. The repository had been
created as tidyckj, with the last two letters transposed, and the typo
reached the URL and BugReports fields, the pkgdown site, the
R-CMD-check badge and the pak::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.Not fixes, since these verbs are new, but the details most likely to surprise you, gathered in one place.
stri_sort(),
stri_split_boundaries() and stri_wrap() all read a leading U+FEFF as a
byte-order mark and drop it. cjk_sort(), cjk_sentences() and
cjk_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 to
cjk_wrap(), because re-flowing can put it at the start of a segment;
?cjk_wrap says so.cjk_sort() is x[cjk_order(x)], so it can only reorder its input.
A sort must not rewrite the values it was handed, and building it on
stri_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 "", where cjk_pad() would have kept it.cjk_wrap() returns NFC, which no other layout verb does. ICU's
line-breaking works on normalised text, so stri_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 = FALSE would stop it, but that
argument also turns off whitespace collapsing and makes stri_wrap()
raise on any string containing a newline, so it is documented and pinned
by a test rather than changed. Call cjk_normalize() first if you want the
normalisation to be explicit.to_katakana(to_hiragana(x))."icu" drops punctuation and symbols along with whitespace;
"character" keeps CJK punctuation as tokens, because those code points
are in blocks cjk_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.locale, and the "icu" engine, shares one
guard, which checks the language subtag against
stringi::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.locale does 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 in cjk_wrap(), which
is why that verb takes one.test-invariants.R checks, over every code point in the block table, that
each normal form is a fixed point of itself, that nfc(nfd(x)) is
nfc(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 is
skip_on_cran(); a sampled pass and every block boundary run everywhere.has_cjk(),
cjk_ratio() and cjk_script() agree with the block table in both
directions, no character wrongly called CJK and none wrongly left out, and
that cjk_width() is never missing and never outside 0:2.RoxygenNote replaces Config/roxygen2/version: man/ is generated by
roxygen2 7.3.2, and the file now records what actually built it.data-raw/unihan.R downloads and parses it, but nothing is bundled yet.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 list cjk_jamo() returns. It would
make the round trip compose directly instead of needing the vapply()
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() and cjk_segment() also return lists, and
cjk_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.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.
cjk_summary(data, col) reports n_docs, n_with_cjk, prop_with_cjk and
mean_ratio for a text column.cjk_char_counts(data, col) returns one row per distinct CJK character with
its code point, script, Unicode block and count.cjk_segment(x, engine) splits CJK text into words, and
cjk_tokens(data, col, engine) is the tidy version, returning one row per
token.cjk_segmenters() lists what is
available and register_cjk_segmenter() adds an engine: any function of
(x, ...) returning a list of character vectors.engine has 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_segmenters shows the four lines that
register it once you have installed it from source."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.has_cjk(), cjk_script() and cjk_ratio() classify and measure.cjk_detect_language() infers the language from the scripts present.cjk_width(), cjk_pad() and cjk_truncate() work in terminal columns
rather than characters.to_halfwidth() and to_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.Language detection returns NA rather than guessing. Japanese written
without kana is not distinguishable from Chinese by script alone, so a
Han-only string gets NA. 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() and cjk_pad() wrap stringi::stri_width() and
stringi::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 no stringi equivalent and is implemented here.
Normalisation is surgical. to_halfwidth() maps fullwidth ASCII, the
ideographic space and halfwidth katakana, and touches nothing else. NFKC
additionally 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 = TRUE folds 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() and cjk_char_counts()
break ties by first appearance rather than by collation order.
data-raw/unihan.R downloads and parses it, but no character data is
hand-written or bundled yet.
pinyin and
hanyupinyin are on CRAN
today.