Bash対応のUnicodeコードポイント表記を出力するシェル芸

この記事はシェル芸 Advent Calendar 2019 19日目の記事です。

8ヶ月前、UTF-8のデコードをするべく我々はジャングルの奥地へと向かったという記事で自力でコードポイントを算出するシェルスクリプトを公開しました。
その文中で「(UTFデコード)変換を行ってくれるツールは、Linux標準環境で探した限り見当たらない」と書いていたのですが、これが自分の調査不足であったことがのちの調査で分かりました。

Bash対応の表記とは

ひとことで言うと、 "\uHHHH" あるいは "\UHHHHHHHH" 形式のエスケープ表記のことです。

このようなエスケープ表記は正確にはANSI C Quotingと呼ばれます。
\a\nなどの基本的な制御文字のほか、 \nnn (8進数) \xHH (16進数)などの表記も用意されており、その中に \uHHHH (Unicodeコードポイントを表す16進数)と \UHHHHHHHH (同)が存在します。
本記事ではこれら2つの表記をコードポイント表記と呼ぶことにします。

(注:コードポイント表記までもが本当にC標準なのかどうかは仕様書を読んでいないのでわかりません。知っている人教えて)

例えば以下のように使うことができます。

$ echo -e "\u3046\u3093\u3053"
うんこ
$ echo -e "\U0001f4a9"
?

16進数4桁で表せるものは小文字のu、それでは桁が溢れてしまうものは大文字のUを使います。
もちろん大は小を兼ねるので全部大文字のUにして0埋めしても構いません。
なんでこれを例に出したかって?……シェル芸界に蔓延っているから

本記事で紹介するシェル芸

さて本題です。
「コードポイント表記→実際の文字」は echo -e (zshであれば -e オプションはいらない)で簡単に変換できますが、逆に「実際の文字→コードポイント表記」は標準環境のコマンド一発ではできないので、これをなんとかするというものです。

要件は以下のようにします。

  • 標準入力で文字列を受け取ると、対応するコードポイント表記が出力される
  • \uHHHH で記述できるものについては、そちらを使う(全部 \UHHHHHHHH にはしない)
  • 文字列でない入力は考慮しない

出来上がったものがこちら

Bashでの実行が必須です。

$ echo -n "ここに変換したい文字列を入れる" | while read -N1 c; do d=$(echo -n "$c" | iconv -t UCS-2BE | xxd -p); if [[ "$d" == "fffd" ]]; then echo -n "$c" | iconv -t UCS-4BE | xxd -p | xargs printf '\\U%s'; else printf '\\u%s' $d; fi; done

解説

STEP1: UCS-2変換(\uHHHH)

一般にUnicodeのコードポイント表記でおなじみなのはこちらです(筆者談)。

emojiでない大抵の文字はこちらに収まります[本当?]

まずはUCS-2の説明からしましょう。
現在UnicodeはUTF-8、UTF-16、UTF-32といった様々なバイト長のエンコーディングがありますが、それ以前はUCS-2、UCS-4と呼ばれる「文字⇔固定長コードポイント」の相互マッピングが用いられていたそうです[1]

そしてこのUCS-2、UCS-4のコードポイントこそ、一般的にU+HHHHと表記されている16進数部分に相当するものになります(2や4はバイト数を示している)。

"日本語"(U+65E5 U+672C U+089E)の各文字の対応
+---------------------|
| UTF-8   <-->  UCS-2 |
+---------------------|
| E697A5  <-->  65E5  |
| E69CAC  <-->  672C  |
| E8AA9E  <-->  8A9E  |
+---------------------+

POSIXには文字コード変換ユーティリティとして iconv コマンドが定められています。
扱える文字コードを調べるために man iconv_open (GNU libiconv 1.11)を見てみると

Full Unicode
    UTF-8
    UCS-2, UCS-2BE, UCS-2LE
    UCS-4, UCS-4BE, UCS-4LE
    UTF-16, UTF-16BE, UTF-16LE
    UTF-32, UTF-32BE, UTF-32LE
    UTF-7
    C99, JAVA

とあります。今回はここにある UCS-2BE(Big Endian) を用います。

iconv で出力されたUCSは16進数表記のasciiではなくバイナリそのものなので、 xxd で変換しましょう。
xxdには -p (目的のhexだけ出力する)のオプションがあります。

$ echo -n あいうえお | iconv -t UCS-2BE | xxd -p
3042304430463048304a

UCS-2は文字通り2バイトの固定長なので、hexにすると4桁になります。
次はこれを4桁ごとに分割するために fold を使いましょう。

$ echo -n あいうえお | iconv -t UCS-2 | xxd -p | fold -w4
3042
3044
3046
3048
304a

それっぽくなってきました。あとは先頭に "\u" をつければ完成です。

$ echo -n あいうえお | iconv -t UCS-2 | xxd -p | fold -w4 | xargs -n1 printf '\\u%s'
\u3042\u3044\u3046\u3048\u304a

STEP2: UCS-4変換(\uHHHHHHHH)

察しの良い皆様ならお分かりのとおり、UCS-4は4バイト固定長のコードポイントなのでやることはあまり変わりません。

UCS-2でも表現可能なものについては先頭2バイトが0埋めされるだけですが、UCS-2では桁溢れで表現不可能になるものについてはこちらでの表記が必須になります。

そしてUCS-4が求められる文字の代表格がemojiです。
といっても肝心の変換シェルに関しては大して変わらないので、いきなり完成形を示します。
fold のオプションが8になっているのと、 "\U" が大文字になっていることに注意です。

$ echo -n ??? | iconv -t UCS-4 | xxd -p | fold -w8 | xargs -n1 printf '\\U%s'
\U0001f914\U0001f607\U0001f602

STEP3: UCS-2とUCS-4の判別

さて、大は小を兼ねると書いたとおり全てのコードポイント表記をUCS-4で変換してしまえば良いので、実用にはSTEP2で十分です。
しかしあろうことか

  • \uHHHHで記述できるものについては、そちらを使う(全部 \UHHHHHHHH にはしない)

を要件に入れてしまったので、UCS-2で表現可能かどうかの判定をしなければなりません。

ここで問題です。
UCS-2で表現できない文字を無理やりUCS-2に変換すると何が起こるでしょうか?

正解は「U+FFFD が返される」です。
The Unicode Consortiumでは以下のように紹介されています。

FFFD REPLACEMENT CHARACTER
• used to replace an incoming character whose value is unknown or unrepresentable in Unicode

https://unicode.org/charts/PDF/UFFF0.pdf

したがって、「とりあえずUCS-2で変換してみて、U+FFFDだったらUCS-4にする」が手っ取り早いでしょう。

改めての登場になりますが、完成したスクリプトが以下です。

$ echo -n "ここに変換したい文字列を入れる" | while read -N1 c; do d=$(echo -n "$c" | iconv -t UCS-2BE | xxd -p); if [[ "$d" == "fffd" ]]; then echo -n "$c" | iconv -t UCS-4BE | xxd -p | xargs printf '\\U%s'; else printf '\\u%s' $d; fi; done

順を追って解説しましょう。

while read -N1 c; do

上記コマンドはEOFが来るまで1文字ずつ ( -N1 ) 読み込みます。幸いBashはバイト長に左右されず正しく1文字を読み込んでくれるため安全です。
読み込んだ文字は $c に代入されます。

d=$(echo -n "$c" | iconv -t UCS-2BE | xxd -p)

$d にはUCS-2BEでデコードされた4桁の16進数が代入されます。
もしUCS-2では表現できない文字が来た場合、 "fffd" が代わりに代入されます。

if [[ "$d" == "fffd" ]]; then echo -n "$c" | iconv -t UCS-4BE | xxd -p | xargs printf '\\U%s'

"fffd" が返ってきた文字についてはUCS-4で対処します。これはSTEP2と同様です。

else printf '\\u%s' $d

UCS-2で正しく表現できるものはそのまま出力します。

Q&A

飛んできそうな質問に答えるコーナー

Q. “while read -N1” より “grep -o .” でパイプしたい!

A. 制御文字(特に改行)が変換できなくなってしまうのでダメです。

Q. zshで動くようにしたい!

A. read の引数を変えてあげると動きます。

echo -n "ここに変換したい文字列を入れる" | while read -ku0 c; do d=$(echo -n "$c" | iconv -t UCS-2BE | xxd -p); if [[ "$d" == "fffd" ]]; then echo -n "$c" | iconv -t UCS-4BE | xxd -p |xargs printf '\\U%s'; else printf '\\u%s' $d; fi; done

Q. なんでxxdなんか使ってるんですか

A. POSIX原理主義者の方はこちらをどうぞ( read は変更するの面倒なので各自でどうぞ)

$ echo -n "ここに変換したい文字列を入れる" | while read -N1 c; do d=$(echo -n "$c" | iconv -t UCS-2BE | od -An -vtx1 | tr -d ' '); if [[ "$d" == "fffd" ]]; then echo -n "$c" | iconv -t UCS-4BE | od -An -vtx1 | tr -d ' ' | xargs printf '\\U%s'; else printf '\\u%s' $d; fi; done

おまけ

URLのパーセントエンコードはUTF-8のバイナリを1バイトごとに区切っているだけなので、 nkf -WwMQ を使ったあとに無駄な改行を削ったり "=""%" にわざわざ置換したりしなくても、以下のように直感的に書くことができます。

$ echo -n あいうえお | iconv -t UTF-8 | xxd -p -u | sed 's/\(..\)/%\1/g'
%E3%81%82%E3%81%84%E3%81%86%E3%81%88%E3%81%8A
# ターミナル環境の文字コードが元々UTF-8ならiconvはいらない
$ echo -n あいうえお | xxd -p -u | sed 's/\(..\)/%\1/g'
%E3%81%82%E3%81%84%E3%81%86%E3%81%88%E3%81%8A

コマンド入力コストはそんなに変わらないですね(真顔)

参考文献

[1] 「UnicodeとUTF-8とUCS-2の関係 ――符号化文字集合? 文字符号化方式?」プログラマのための文字コード技術入門(WEB+DB PRESS plusシリーズ)|gihyo.jp … 技術評論社 http://gihyo.jp/magazine/wdpress/plus/978-4-7741-4164-0/0002

コメント

  1. […] Bash 対応の Unicode コードポイント表記を出力するシェル芸 | Ryoto’s Blog […]

タイトルとURLをコピーしました