--- title: "Width, padding, truncation and wrapping" author: 'Youzhi Yu
University of Chicago' bibliography: tidycjk.bib output: rmarkdown::html_vignette: toc: true toc_depth: 3 vignette: > %\VignetteIndexEntry{Width, padding, truncation and wrapping} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, message = FALSE, warning = FALSE, comment = "#>" ) ``` ```{r setup} library(tidycjk) ``` ## One character is not one column This is the whole vignette in one comparison: ```{r basic} labels <- c("中文", "abcd", "日本語") nchar(labels) # characters cjk_width(labels) # terminal columns ``` `中文` is two characters and four columns. Unicode Annex #11 assigns every character an East Asian Width, and the values `Wide` and `Fullwidth` mean two columns in a monospaced terminal [@uax11]. `cjk_width()` reports that number. Everything that goes wrong with CJK in a console follows from code that used the first number where it needed the second. ```{r ragged} pad_by_char <- function(x, n) paste0(x, strrep(" ", n - nchar(x))) cat(paste0("|", pad_by_char(labels, 8), "|"), sep = "\n") ``` Three lines that should be the same width, and are not. Now with the column count: ```{r aligned} cat(paste0("|", cjk_pad(labels, 8), "|"), sep = "\n") ``` ## `cjk_pad()` Pads to a width in columns. `side` names the side the padding goes on, and `pad` must itself be one column wide: a two-column pad character cannot hit an odd target, so it is rejected rather than allowed to overshoot. ```{r pad} cjk_pad("中文", 10) cjk_pad("中文", 10, side = "left") cjk_pad("中文", 10, side = "both") cjk_pad("中文", 10, pad = ".") ``` A width already exceeded is left alone rather than truncated, because padding and truncation are separate verbs on purpose: ```{r pad-over} cjk_width(cjk_pad("日本語テキスト", 4)) ``` ## `cjk_truncate()` Cuts to a width, again in columns, appending an ellipsis that is itself counted against the budget: ```{r truncate} cjk_truncate("我今天很開心", 8) cjk_width(cjk_truncate("我今天很開心", 8)) ``` The cut never lands inside a character. Because a CJK character is two columns, an odd budget can leave one column unused, and leaving it empty is the only correct option: ```{r truncate-odd} cjk_truncate("中文中文", 5, ellipsis = "") cjk_width(cjk_truncate("中文中文", 5, ellipsis = "")) ``` ## `cjk_wrap()` Breaks a string into lines that each fit the budget. This is where counting columns stops being sufficient, because *where* you are allowed to break is its own question. ```{r wrap} x <- "我今天很開心,因為天氣非常好而且朋友來看我" cat(cjk_wrap(x, 14)) ``` Lines are filled greedily, the way a terminal fills them: each takes as much text as fits. Every line is inside the budget: ```{r wrap-check} cjk_width(strsplit(cjk_wrap(x, 14), "\n", fixed = TRUE)[[1]]) ``` ### Why this is not "split every N columns" CJK text is *mostly* breakable between any two characters, since there are no spaces to break at, but not everywhere. Unicode Annex #14 defines the positions [@uax14], and several are forbidden: * a closing bracket `)` may not begin a line; * a full stop `。` may not begin a line; * an opening bracket `(` may not end one. A naive chunker violates all three. `cjk_wrap()` uses ICU's implementation of the algorithm, so the break positions are the ones a typesetter would choose: ```{r wrap-punct} cat(cjk_wrap("他說(今天天氣很好)。我們去公園散步。", 12)) ``` Notice that no line begins with `。` or `)`. ### One rule that is not universal Those three hold whatever the locale. A fourth, whether a small kana may be separated from the character it follows, does **not**. Annex #14 defines strict, normal and loose line-breaking styles and which applies is tailored per locale: under `"en"` a small kana never begins a line, and under `"ja"`, which uses the looser style, it may. Name the style when the output has to be reproducible: ```{r wrap-style} kana <- "きょうはとてもいいてんきですっしゃちょ" cat(cjk_wrap(kana, 6, locale = "ja@lb=strict")) ``` Latin runs inside the same string still break on spaces, so mixed text behaves the way you would expect from both sides: ```{r wrap-mixed} cat(cjk_wrap("tidycjk 讓中日韓文字在終端機裡對齊 and it handles Latin too", 20)) ``` ### Indents ```{r wrap-indent} cat(cjk_wrap(x, 14, exdent = 2)) ``` ## The shared contract All three layout verbs behave identically at the edges, which is what makes them safe inside a pipeline: ```{r contract} cjk_pad(c("中", NA), 6) cjk_truncate(character(0), 6) cjk_wrap(NA, 6) ``` A `width` that is `Inf` or outside integer range is an error rather than a silent `NA`. `cjk_pad(x, Inf)` is a plausible way to write "do not truncate", and coercing it would return missing values with only a coercion warning: ```{r contract-error, error = TRUE} cjk_pad("中文", Inf) ``` Ragged recycling is an error too, naming both lengths, because a plausible vector of the wrong length is worse for a layout function than a refusal: ```{r contract-recycle, error = TRUE} cjk_pad(c("a", "b", "c"), c(2, 3)) ``` ## Ambiguous width Some characters (Greek, Cyrillic, a few punctuation marks) are `Ambiguous` in Annex #11: two columns in a legacy East Asian font, one otherwise. There is no context-free answer, and ICU has changed its treatment more than once. `cjk_width()` reports what ICU reports on your machine. If your output must align in a specific terminal, measure there rather than assuming: ```{r ambiguous} cjk_width(c("α", "→", "±")) ``` ## References