knitr::kable()が全角文字でずれる問題が(ほぼ)直った

knitr 1.10がCRANに来てました。このバージョンで直ってるバグの話をします。地味です。

どういうバグか

knitrにはkable()という関数があります。knitしたときにdata.frameとかをいい感じの表として出力してくれる関数です。これに、全角文字が3つ以上あると列がずれるというバグがありました。

これはPandocとの相性の問題で、format="pandoc"のときだけ起こります。knitするとき「knitrがテキストを生成 → それをpandocが各種フォーマットに変換」という流れで処理が進みますが、knitrは1文字の幅をすべて1として扱っていた一方、Pandocは全角文字を幅2として律儀に扱っています。つまり、全角文字ひとつにつき幅ひとつの差が生じることになります。

format="pandoc"の場合、各列は空白2つ分で区切られているので、全角文字が3つ以上あると隣の列まではみ出てしまうことになります。(↓はknitr 1.9までの結果)

a <- read.csv(text = '
    "A",  "B", "C",  "D",  "E"
    "a",  "a", "a", 1.23, 1.23
    "囧", "a", "a", 1.23, 1.23
    "囧","囧","囧", 1.23, 1.23
    "囧囧囧","a","a", 1.23, 1.23', stringsAsFactors = FALSE)
kable(a, format="pandoc")
#> 
#> 
#> A     B     C        D      E
#> ----  ----  ---  -----  -----
#> a       a    a    1.23   1.23
#> 囧      a     a    1.23   1.23
#> 囧     囧     囧     1.23   1.23
#> 囧囧囧   a     a     1.23   1.23

kohskeさんに教えてもらいましたが、これは例えばformat="markdown"だと明示的に|で各列を区切っているので、ずれても問題ありませんでした。

kable(a, format="markdown")
#> 
#> 
#> |A   |B   |C  |    D|    E|
#> |:---|:---|:--|----:|----:|
#> |a   |  a | a | 1.23| 1.23|
#> |囧   | a  | a | 1.23| 1.23|
#> |囧   |囧   |囧  | 1.23| 1.23|
#> |囧囧囧 |a   |a  | 1.23| 1.23|

この問題が1.10だと直っていて、ちゃんと揃った出力になってます。

kable(a, format="pandoc")
#> 
#> 
#> A        B    C        D      E
#> -------  ---  ---  -----  -----
#> a        a    a     1.23   1.23
#> 囧       a    a     1.23   1.23
#> 囧       囧   囧    1.23   1.23
#> 囧囧囧   a    a     1.23   1.23

で、これを直してもらった経緯をここにメモっておきます。

とりあえずバグ報告してみた

なんかkohskeさんが回避方法も含めて知ってたので、これはバグっていうかknown issueなのかなあと思いつつ、GithubのIssuesには報告なかったので一応報告してみました。

github.com

そしたら、yihuiさんがこんな男前なコメントをくれます。

All I can tell you at the moment is the possible direction to fix it: nchar(x, 'width') gives the width of characters. Then look for str_pad in the source code: https://github.com/yihui/knitr/blob/master/R/table.R And try to fix it by yourself, or find an expert to do it :) I will be waiting for a pull request. Thanks!

ここまで分かってるなら自分で書いた方が早そうなのにあえて宿題を出してくる感じ、かっこいいです。

Pull Requestを出してみた

で、まあ言われたとおりにPRをつくって投げました。

github.com

ダメダメなコードだったんですが、「あとは俺に任せな!」的な男前コメントでマージしてもらえました。

That works, but I guess it is slow. I will improve it by myself later. Thanks!

その後100倍速いコードに置き換えられたので、私のコントリビューションは塵芥のようなものです。にも関わらず、なんとknitrのコントリビューター認定されてしまいました。恐縮です。。

github.com

まったくコントリビュートできてる気がしなくて申し訳なさすぎるので、もっといいコード書けるようになって、いつかちゃんと貢献したいです。

ついでにstringiにもFeature Requestを出してみた

と、一件落着的な雰囲気を出してしまいましたが、kable()がすべての文字に対応できてるかといえば実はまだそうではありません。実際に問題にでくわすことはあまりない気がしますが、内部的に使っているnchar()は雑な挙動をしています。

Mac OS濁点問題」とかいう感じで知られている気もしますが、濁点とかアクセント記号がついた文字の表し方には、濁点とかアクセントも含めてひとつの文字とみなす方法と、濁点とかアクセントは別の文字とみなす方法があります。

参考:MacOSX - Mac OS X の NFD 問題での対策諸々 - Qiita

具体的にはたとえば「ヅ」という文字には以下の2通りがありえます。

a <- "\u30c5"
a
#> [1] "ヅ"
b <- "\u30c4\u3099"
b
#> [1] "ヅ"

後者の幅を、nchar()は正しく判定できません。

> nchar(a, type = "width")
[1] 2
> nchar(b, type = "width")
[1] 1

これは、なぜか、結合文字(濁点とかのほう)の幅を-1と判定するからです。

nchar("\u3099", type = "width")
#> [1] -1

baseの文字列の扱いがイケてない、と思ったときに考え付くことはひとつ。そう、stringiにイシューを立てることです。(いや、別にこの問題に気付いたからイシューを立てたわけじゃないんですけど...)

何とかしてくださいアニキ!と泣きついたところ、機能が実装されました。言ってみるものですね。すてき。

github.com

stringi 0.5で入るらしいので、これがCRANに来たらまたknitrに報告しようかなと思ってます。

まとめ

  • yihuiさんは男前
  • 文字コードの闇
  • baseはイケてないのでstringiに期待