RubyのCSV文字コードの取り扱い方決定版

WEMEX株式会社 Advent Calendar 2023の5日目の記事です。
WEMEXでは、調剤薬局の本部向けに調剤売上等を店舗跨いで確認できるBIツールを提供しています。BIツールを提供する上で、CSV出力できる機能は欠かせないものです。私はこれまでもCSV出力の機能を開発してきましたが、今回、改めてCSVを実装する上での文字コードの取り扱いについて整理しましたので記事にしました。なお、CSVとタイトルに入れていますが、本質的にはCSV以外でも文字コードを取り扱う際に適用できる考え方だと思います。

# はじめに

RubyはデフォルトでUTF-8で出力するので、シンプルに `CSV.generate` してエクセルでファイルから開こうとすると文字化けしてしまいます。もちろん、エクセルでは、フォーマットを指定して開けば文字化けせずに開くこともできますが、少々手間です。業務においてCSVはその後エクセルで処理されることが多いので、システムから出力する場合、文字化けせずに開ける様にしてあげるのが基本です。
Googleで検索するとエクセルで文字化けせずに出力する場合、主に以下の２つの方法が出てきます。

 - CP932またはShift_JISでエンコードする方法
 - BOMつきでUTF-8で出力する方法

本記事では、さまざまに散らばっているRubyによるCSV生成のエンコードについて整理し、それぞれどのような場面で利用すべきかを記載します。

# 結論
まず、簡単に結論だけ記載します。
 - UTF-8で良い場合は（CP932やShift_JISである必要がなければ）、BOMつきでUTF-8で出力
 - CP932/Shift_JISである必要がある場合は、CP932/Shift_JISでエンコードエラーになる文字の取り扱いを考慮する必要がある（詳細は後述）

# BOMつきUTF-8で出力する
CP932やShift_JISで出力する必要がない場合は、BOMつきUTF-8で出力するのが文字化けもなく、便利でしょう。
BOMとは、Byte Order Markの略で、Unicodeで符号化したテキストの先頭に付与される数バイトにデータにのことです。BOMによって、ビッグエンディアンかリトルエンディアンかを判別する仕様になっています。BOMがない場合は、ビッグエンディアンになります[^1]。
[^1]: https://itskillmap.com/menu2_02/

しかし、Microsoft ExcelやWindowsのメモ帳では、BOMがないと、UTF-8なのか、UTF-16なのか、UTF-32なのか判断ができなく、文字化けしてしまいます。UTF-8の場合、ビッグエンディアン一択であるのでBOMは不要なのですが、ExcelではBOMがないと正しく判断してくれないという状況というわけです。
そこで、RubyでUTF-8でCSV出力する場合は（システム連携などでBOM不要の場合を除き）、先頭にBOMをつけてあげます。具体的なコードはリスト1の様になります。

```ruby
require "csv"

bom = %w(EF BB BF).map { |e| e.hex.chr }.join
rows = CSV.generate(bom) do |csv|
  csv << ["ヘッダー名１", "ヘッダー名2"]
  csv << ["値1", "値2"]
end
```

# CP932で保存する
 要件によっては、UTF-8は不可でCP932または、Shift_JISで出力する必要がある場合があると思います。CP932とShift_JISの違いですが、Shift_JISは、JIS X 0208という規格で定められている文字集合を符号化するものですが、CP932は、JIS X 0208にいくつかの文字を追加したもので、扱える文字の種類は増えています。
 具体的には、名前に使われる「﨑」や「髙」（区別するために「たちさき」や「はしごだか」と呼ばれることもある）は、Shift_JISにはありませんが、CP932にあります。これらの文字が入った文字列をShift_JISでエンコードしようとすると、エラーになります。

```ruby
# Shift_JISでエンコードするとエラーになる
> "﨑".encode(Encoding::Shift_JIS)
Encoding::UndefinedConversionError: U+FA11 from UTF-8 to Shift_JIS
> "髙".encode(Encoding::Shift_JIS)
Encoding::UndefinedConversionError: U+9AD9 from UTF-8 to Shift_JIS
# CP932ではエラーにならない
> "﨑".encode(Encoding::CP932)
=> "\x{FAB1}"
> "髙".encode(Encoding::CP932)
=> "\x{FBFC}"
```
したがって、多くの場合、Shift_JISではなく、CP932でエンコードをすることが多いと思います。例えば、WindowsのアプリではShift_JISで保存した場合、内部ではCP932を使っているケースも多いです。また、私の経験上ですが、以前クライアントが出してきた仕様書にShift_JISで連携と記載があったものの、よくよく聞いてみるとCP932であったというケースもあります。
なお、Windows_31Jという文字コードもありますが、Rubyにおいては、 `Encoding::CP932 == Encoding::Windows_31J` になります。
上記の理由から、本記事では、CP932で出力することが多いため、Shift_JISに出力する話は記載しませんが、取り扱いについては、CP932と大きな違いがないため、Shift_JISで出力しないといけない場合は、うまく読みかえてください。

CP932とShift_JISの違いについて述べてきましたが、CP932であろうと、Shift_JISであろうと、UTF-8から変換する場合に注意しなければいけないのは、UTF-8には含まれる文字がCP932やShift_JISには含まれない文字があるということです。例えば、「𠮷」（つちよし）は典型的な例で、CP932には含まれていません。「𠮷」が含まれる文字をCP932でエンコードしようとすると、エラーになります。他にも、「〜」（`U+301C`）などの記号もエラーになります（「～」（`U+FF5E`）はあります）。

```ruby
> "𠮷".encode(Encoding::CP932)
Encoding::UndefinedConversionError: U+20BB7 from UTF-8 to Windows-31J
> "〜".encode(Encoding::CP932)
Encoding::UndefinedConversionError: U+301C from UTF-8 to Windows-31J
```
その場合、CP932に変換するときにどう扱えばいいでしょうか。やり方として二つあります。
一つは、変換できない文字を強制的に一律`?`などに置換する方法です。RubyのString#encodeメソッドには、`undef`のパラメータ引数をとることができ、`undef: :replace`と指定すると、CP932であれば、`?`に置換されます。

```ruby
> "あい𠮷うえ".encode(Encoding::CP932, undef: :replace)
=> "\x{82A0}\x{82A2}?\x{82A4}\x{82A6}"

# invalidも一緒に指定してしまい、
> "あい𠮷うえ".encode(Encoding::CP932, undef: :replace, invalid: :replace)
# とすることも多いと思います。
```
`replace`引数で、置換文字列を変更することができます。

```ruby
"あい𠮷うえ".encode(Encoding::CP932, undef: :replace, replace: "<?>")
=> "\x{82A0}\x{82A2}<?>\x{82A4}\x{82A6}"
```

しかし、特定の文字については、変換した文字を指定したいといったケースもあるでしょう。例えば、
「𠮷」（つちよし）は「吉」に変換でよい、「〜」（`U+301C`）は（「～」（`U+FF5E`）に変換でよいという場合です。その場合は、`fallback`というパラメータ引数を設定します。

```ruby
convert_hash = { "𠮷" => "吉", "〜" => "～" }
"あい𠮷うえ〜お".encode(Encoding::CP932, fallback: convert_hash)
=> "\x{82A0}\x{82A2}\x{8B67}\x{82A4}\x{82A6}\x{8160}\x{82A8}"
```
ただし、fallbackのhashに指定されていない文字が文字列に含まれる場合は、エラーとなってしまいます。

```ruby
> convert_hash = { "𠮷" => "吉", "〜" => "～" }
> "あい𠮷うえ〜おか俱き".encode(Encoding::CP932, fallback: convert_hash) # 俱がCP932に含まれない文字
Encoding::UndefinedConversionError: U+4FF1 from UTF-8 to Windows-31J
```

したがって、fallbackにhashを指定することは行わない方がいいと思います。指定した文字以外は一律`?`に置換する様にし、エラーにならない様にすることが多いのではないでしょうか。その場合は、Procを用いて、hashに含まれる場合はその値を、含まれない場合は`?`にするっといった処理を記述します。

```ruby
> convert_hash = { "𠮷" => "吉", "〜" => "～" }
> "あい𠮷うえ〜おか俱き".encode(Encoding::CP932, fallback: ->(v) { convert_hash.fetch(v, "?") })
=> "\x{82A0}\x{82A2}\x{8B67}\x{82A4}\x{82A6}\x{8160}\x{82A8}\x{82A9}?\x{82AB}"
```

"𠮷"（つちよし）を"吉"に変換するのは多少過激なので行わないにしても、「〜」（`U+301C`）を「～」（`U+FF5E`）に変換するというのは往々にして実施して良いというケースも多いでしょう。他にも、「−」（`U+2212`）を「ー」（`U+30FC`）にしたいといった記号に関しては変換するといった仕様の整理をする場合もあると思うので、その場合は、hashに定義しておくと良いでしょう。
なお、rubyにおいて、ユニコードのコードで指定する場合は、`\u`をつけて、ダブルクオーテーションで囲みます。シングルクオーテーションではだめです。

```ruby
> "\u2212"
=> "−"
> "\u{2212 FF5E}" # {}を使うと複数指定できる
=> "−～"
> '\u2212'
=> "\\u2212"
```
したがって、変換用のhashを`{"〜"=>"～", "−"=>"ー"}`と用意するのも良いですが、人間の目では、どの文字コードか判別するのが難しいため、この場合においては、あえて文字コードで定義するのも良いと思います。

```ruby
convert_hash = {
  "\u301C" => "\uFF5E", # 〜
  "\u2212" => "\u30FC", # ハイフン（−）
  "\u2014" => "\u2015", # ハイフン（—）
  "\u2016" => "\u2225", # ‖を∥へ
}
require "csv"
rows = CSV.generate do |csv|
  csv << ["ヘッダー名１", "ヘッダー名2"]
  csv << ["値1", "値2"]
end
rows.encode(Encoding::CP932, fallback: ->(v) { convert_hash.fetch(v, "?") })
```

変換用hashにどのような値を定義しておくべきかに関しては、各プロジェクトごとで規定していくべきものになりますが、一例を挙げると以下の様なものは、変換してあげてもいいのではないかと思います。

下記は、CP932にあるUnicodeとないUnicodeの一例ですが、他にもあります。詳細は[UTF-8 → cp932(Shift_JIS)変換表](https://sites.google.com/site/fudist/Home/vim-nihongo-ban/mojibake/utf8-cp932conv)などの他のサイトをご覧ください。

|CP932||CP932にあるUnicode||CP932にないUnicode|
|---|---|---|---|---|
|0x8160|～|U+FF5E|〜|U+301C|
|0x8161|∥|U+2225|‖|U+2016|
|0x817C|－|U+FF0D|−|U+2212|
|0x815C|―|U+2015|—|U+2014|

# まとめ
本記事では、RubyにおけるCSV文字コードの取り扱いについて記載しました。タイトルにCSVとつけましたが、実際には、CSV関係なくRubyでエンコードする際の一般的な処理になります。
紹介した一つ一つのことは、他記事でも記述のあり、Rubyのリファレンスにも記載がありますので、目新しいことはありませんが、Rubyでエンコードするにあたって考慮すべきことを広くできる限り体系的に把握できるようにあえて記事にさせていただきました。
まとめると、
 - UTF-8で良い場合は（Shift_JISやCP932である必要がなければ）、BOMつきでUTF-8で出力
 - CP932/Shift_JISである必要がある場合は、CP932/Shift_JISでエンコードエラーになる文字（未定義文字）の取り扱いを考慮してエンコードする

ということになります。

# おまけ
## Shift_JISからUTF-8に変換する場合
Shift_JISからUTF-8に明示的に変換する場合、外字などでUTF-8に存在しない文字がある場合、変換に失敗することがあります。その場合、以下の様に`force_encoding`を利用すれば変換できます。
ここで、明示的にと記載したのは、単に`string.encode`と行うと、変換できなかった文字に関しては、rubyは自動で`U+FFFD`（�）に置き変えるからです。`string.encode(Encoding::UTF_8)`と行うとエラーが発生します。
```ruby
convert_hash = {
  "\xF9\x5C".force_encoding(Encoding::Shift_JIS) => "", # 薏に相当
  "\xF9\x5D".force_encoding(Encoding::Shift_JIS) => "", # 芎に相当
}
string.encode(
  Encoding::UTF_8, fallback: ->(v) { convert_hash.fetch(v, "?") }
)
```