記事一覧へ
12/10/2024

AWKで日本語の踊り文字を解決しようとしてロケールでハマった話

AWKで日本語の踊り文字を解決しようとしてロケールでハマった話

本記事はAkatsuki Games Advent Calendarの25日目の記事です。メリークリスマス!

以前10日目の記事で、「AWKで日本語の踊り文字を解決したかったけどできなかった話」を書きました。 先日の記事ではマルチバイト文字に対してmatchを実行すると、マッチ箇所が文字の末尾になってしまうという問題が発生しました。

しかし、実はこれ、AWKの仕様ではなく、拍子抜けするくらいしょーもない原因があったことが発覚したので、今回はその内容を紹介します。

原因:Locale

ブログ公開当日、読んでいただいたネコの人に「めっちゃlocaleにはまっているような…」と指摘をもらい、localeを確認してみることにしました。

$ locale
LANG=ja_JP.UTF-8
LC_CTYPE="ja_JP.UTF-8"
LC_NUMERIC="ja_JP.UTF-8"
LC_TIME="ja_JP.UTF-8"
LC_COLLATE="ja_JP.UTF-8"
LC_MONETARY="ja_JP.UTF-8"
LC_MESSAGES="ja_JP.UTF-8"
LC_PAPER="ja_JP.UTF-8"
LC_NAME="ja_JP.UTF-8"
LC_ADDRESS="ja_JP.UTF-8"
LC_TELEPHONE="ja_JP.UTF-8"
LC_MEASUREMENT="ja_JP.UTF-8"
LC_IDENTIFICATION="ja_JP.UTF-8"
LC_ALL=

ふむふむ、LANGはja_JP.UTF-8ですよと。一応インストール済みのロケール一覧を出してみるか…

$ localectl list-locales
C.UTF-8

( ՞`ਊ ՞´)ハイッテイナイ…ダト?

試しにexport LANG=C.UTF-8を設定してみたところ…

$ echo あゝ | awk '{ print gensub(/([ぁ-ん])ゝ, "\\1\\1", "g", $1) }'
ああ

なんと、動かせずに苦労していたgensubが動きました。

というわけで、私が日本語の処理に必要だと思って深く調べずにLANG=ja_JP.UTF-8を追加した結果、存在しないロケールを名指しているため、マルチバイト非対応のロケールにフォールバックしていたことが原因でした。

Localeの何がバグを招いたのか

とはいえ、一口にLocaleと言っても色々あります。localeを叩いたときに出てくるそれぞれのLC_*がそれぞれ役割を持っていることからも、Localeの担う役割の幅広さが伺えます。

LC_CTYPE="C.UTF-8"
LC_NUMERIC="C.UTF-8"
LC_TIME="C.UTF-8"
LC_COLLATE="C.UTF-8"
LC_MONETARY="C.UTF-8"
LC_MESSAGES="C.UTF-8"
LC_PAPER="C.UTF-8"
LC_NAME="C.UTF-8"
LC_ADDRESS="C.UTF-8"
LC_TELEPHONE="C.UTF-8"
LC_MEASUREMENT="C.UTF-8"
LC_IDENTIFICATION="C.UTF-8"

下記の挙動を見ると、このうち今回バグの原因となったのはLC_CTYPEのようです。これは文字数の計算や判定で使用するロケールを管理する変数です。

$ export LANG=ja_JP.UTF-8
$ export LC_CTYPE=C.UTF-8
$ echo あゝ | awk '{ print gensub(/([ぁ-ん])ゝ, "\\1\\1", "g", $1) }'
ああ

Localeには気をつけろ

今回は私が適当に環境変数LANGを設定してしまったことが原因でこのような現象が発生しましたが、巷では以外と様々な場面でLocaleによる挙動の違いに悩まされることがありそうです。

例えば、最近のXでmacOSに関するこんなポストが話題になりました。

タネ明かしをすると /usr/share/locale/ja_JP.UTF-8/LC_COLLATE が la_LN.US-ASCII へのリンクになってるからですね。 なので ASCII 以外の文字は全部等価とみなされちゃう。 ja_JP.UTF-8 だけじゃなくて *.UTF-8 は全部そうなってそう。

LC_COLLATEは文字列のソートや正規表現においての照合順序を管理する環境変数なのですが、最近のmacOSでは上記のようにいいかげんなロケールがインストールされてしまっており、日本語のソートがうまくいかない現象が発生しているようです。

他にも、AWS LambdaのRuby 3.3 Runtimeでは、Amazon Linuxには存在しないロケールであるLANG=en_US.UTF-8が指定されており同様の問題が起きていたりするようです。(参考: 『AWS Lambda RuntimeをRuby3.3にしたら外部エンコーディングが変化した話』)

日本語のソートや文字列処理でバグが発生したときは、まずは現在のロケールと、実際にインストールされているロケールを確認してみた方が良さそうですね。

改めて、完成したソースコード

というわけで、当初の目的であった踊り文字の解決スクリプトを完成させてみます。

実際には「ゝ」「ヽ」が繰り返されるときは様々なルールがあるようですが、今回は青空文庫にはそのような用法があまり散見されなかったため、2文字以上繰り返す踊り文字は考慮しないことにしました。

処理の順番としては、

  1. 直前の1文字に置換される踊り文字を処理
  2. 直前の2文字に置換される「/\」を処理
  3. 直前の3文字以上に置換される「/\」を処理

としました。こうすることで、「何度も/\」のように句を繰り返すような用法でも「何度も何度も」と変換することができます。 厳密にやるためには形態素の解析をしてちゃんとルールを作る必要があるかもしれませんが、 今回は作者が適切に句点と読点を打ってくれていると信じ、直前の漢字かなカナの連続にマッチさせる形で進めることにします。

test.awk
#!/usr/bin/awk -f
 
{
    $0 = gensub(/([-])ゝ/, "\\1\\1", "g")
    $0 = gensub(/([-])ヽ/, "\\1\\1", "g")
    while(match($0, /([-])ゞ/, arr)) {
        if(arr[1] != "") {
            repeated = arr[1] add_dakuten(substr(arr[1], 1, 1))
            $0 = substr_replace($0, RSTART - 1, length(arr[1]) + 2, repeated)
        }
    }
    while(match($0, /([-])ヾ/, arr)) {
        if(arr[1] != "") {
            repeated = arr[1] add_dakuten(substr(arr[1], 1, 1))
            $0 = substr_replace($0, RSTART - 1, length(arr[1]) + 3, repeated)
        }
    }
    $0 = gensub(/([-んァ-ン一-])[]/, "\\1\\1", "g")
    $0 = gensub(/([-]{2}|[-]{2}|[-]{2})/\/, "\\1\\1", "g")
    #$0 = gensub(/([ぁ-ん]{2}|[ァ-ン]{2})/″\/, "\\1\\1", "g")
    
    match($0, /([-]{2}|[-]{2})/″\/, arr)
    if(arr[1] != "") {
        repeated = arr[1] add_dakuten(substr(arr[1], 1, 1)) substr(arr[1], 2, 1)
        $0 = substr_replace($0, RSTART - 1, length(arr[1]) + 4, repeated)
    }
 
    $0 = gensub(/([-んァ-ン一-]+)/\/, "\\1\\1", "g")
    print $0
}
 
function substr_replace(str, start, len, replacement) {
    return substr(str, 1, start) replacement substr(str, start + len)
}
 
function add_dakuten(c) {
    dakuten_map["か"] = "が"; dakuten_map["き"] = "ぎ"; dakuten_map["く"] = "ぐ"; dakuten_map["け"] = "げ"; dakuten_map["こ"] = "ご";
    dakuten_map["さ"] = "ざ"; dakuten_map["し"] = "じ"; dakuten_map["す"] = "ず"; dakuten_map["せ"] = "ぜ"; dakuten_map["そ"] = "ぞ";
    dakuten_map["た"] = "だ"; dakuten_map["ち"] = "ぢ"; dakuten_map["つ"] = "づ"; dakuten_map["て"] = "で"; dakuten_map["と"] = "ど";
    dakuten_map["は"] = "ば"; dakuten_map["ひ"] = "び"; dakuten_map["ふ"] = "ぶ"; dakuten_map["へ"] = "べ"; dakuten_map["ほ"] = "ぼ";
 
    dakuten_map["カ"] = "ガ"; dakuten_map["キ"] = "ギ"; dakuten_map["ク"] = "グ"; dakuten_map["ケ"] = "ゲ"; dakuten_map["コ"] = "ゴ";
    dakuten_map["サ"] = "ザ"; dakuten_map["シ"] = "ジ"; dakuten_map["ス"] = "ズ"; dakuten_map["セ"] = "ゼ"; dakuten_map["ソ"] = "ゾ";
    dakuten_map["タ"] = "ダ"; dakuten_map["チ"] = "ヂ"; dakuten_map["ツ"] = "ヅ"; dakuten_map["テ"] = "デ"; dakuten_map["ト"] = "ド";
    dakuten_map["ハ"] = "バ"; dakuten_map["ヒ"] = "ビ"; dakuten_map["フ"] = "ブ"; dakuten_map["ヘ"] = "ベ"; dakuten_map["ホ"] = "ボ";
 
    return dakuten_map[c]
}

濁点の付加は結合文字でも良いかと思いましたが、濁点が付きうる文字はそこまで多くもないのでChatGPTにmapを書いてもらい、関数で変換するように実装しました。

実行してみたところ、以下のように無事に変換されました。

sample.txt
 私はいろ/\不思議な国を旅行して、さま/″\の珍しいことを見てきた者です。名前はレミュエル・ガリバーと申します。
(中略)
 私は、どうかこの紐を解いてくださいと、くゝられていない片方の手で、いろ/\と手まねをして見せました。すると勅使は、それはならぬというふうに、頭を左右に振りました。その代り、食物や飲物に不自由させぬから安心せよ、と彼は手まねで答えました。
実行結果
$ cat sample.txt | awk -f test.awk
 私はいろいろ不思議な国を旅行して、さまざまの珍しいことを見てきた者です。名前はレミュエル・ガリバーと申します。
(中略)
 私は、どうかこの紐を解いてくださいと、くくられていない片方の手で、いろいろと手まねをして見せました。すると勅使は、それはならぬというふうに、頭を左右に振りました。その代り、食物や飲物に不自由させぬから安心せよ、と彼は手まねで答えました。

まとめ

今回は、先日の記事で発生したAWKの挙動がロケールによるものであり、LANGを適切に設定することで解決できることを紹介しました。

先述の通り、localeが適切に設定されていないことで発生する問題は少なくなく、特に日本語の処理においては文字列のソートや正規表現の挙動が変わるため、注意が必要です。

自分でセットアップした環境はもちろん、クラウドサービスやDockerにおいても、日本語の文字列処理に問題が発生した場合は、一度ロケールを確認してみてください。

あとがき

今回作ったAWKスクリプトを使って『ガリバー旅行記』の踊り文字を変換してみたところ、ちゃんと変換できたので、一章の「リパパット」をTTSに読み上げさせて聞き流してみました。

聞いてみた感想ですが、とても良く眠れました。これからも使っていこうと思います。


書いた人

木瓜丸

Webエンジニア。2022年に「木瓜丸屋」を開業し、個人開発をしています。

その他プロフィールをチェック