【ruby/kconv/nkf】CSVデータをSJISへ変換したら半角カナが全角になって困った話


編集履歴

2018/10/07 string:encode によるエンコーディング方法の追記

この記事のまとめ

  • kconv を使って文字コード変換(tosjisなど)をすると、半角カナは全角になる

    • 例) ホゲホゲ
  • 対策はnkfを使った文字コード変換を行うこと

# -x:半角カナを全角の変換を抑止
NKF.nkf('--sjis -x', csv_str)
  • 文字コード返還はnkfを使った方が良いと感じた
    • nkfkconvと比べて複雑なオプション指定が必要で分かりづらいが、意図しない変換を防ぐためにも、nkfを使った方が良い

2018/10/07_追記箇所

  • 頂いたコメントを参考にして素直に string:encode によるエンコーディングが良い。
    • Unicode系の文字コードを CP932(Windows 31J)といった変換をする際に、変換できない文字をどうするか考える必要がある。
    • nkfは変換できない場合に当該文字が消えてしまうため、事前に適切な変換指定が必要
    • 一方、string:encodeは変換できない場合「?」に変換されること、変換できない場合の動作をオプションで指定できること、変換オプションが多様である。

追記箇所(終了)

こっから本文

上のまとめで言いたいこと全て述べているのですが、それだけでは味気ないので多少コードを交えながら説明します。

動作環境

  • ruby
    • ruby 2.5.1p57 (2018-03-29 revision 63029)
  • rails
    • Rails 5.2.0

CSVの文字コード

Webアプリを作っているとOSやブラウザ毎の差を意識する必要があります。その中でも文字コードは意識しないと文字化けしてしまうことが頻繁にあります。

文字コードについては特にWindows/IE環境を意識する必要があり、SV側のUTF-8からCL側が取得する際はSJISへ変換することで文字化けを防ぐという取り組みはどこでも行なっているかと思います。

今回の文字コード変換を考える上での題材として、railsでcsvダウンロード機能を実装を考えて見たいと思います。

RailsでCSVダウンロード機能を考えてみる

railsでcsvダウンロード機能を実装を考える、だいたい以下のような実装になるかと思います。

ルーティング

formatにcsvを指定して、CSVを取得するルーティングを定義。

config/routes.rb
get 'messages/export', to: 'messages#export', format: 'csv'

コントローラー層

コントローラー層では、レスポンスヘッダーにCSVファイル名と文字コードを指定、及びCSVに含めるリソースを取得(@messages)をするかと思います。

レスポンスヘッダーに文字コードを明示的に指定しないと、ブラウザが文字コードを自動判定してしまうので、IEやFirefoxでファイル名が文字化けしてしまいます。

参考記事:ダウンロードファイル名、文字化けとの格闘

app/message_controller.rb
class MessagesController < ApplicationController
  def export
    filename = "メッセージログ.csv"
    encoded = URI.encode_www_form_component(filename)
    headers['Content-Disposition'] = "attachment; filename=#{filename}; filename*=UTF-8''#{encoded}"

    @messages = Message.where(params)
  end
end

View

最後にViewで、csvの中身(Body)を作成し、文字コードをSJISに変換して完了です。

文字コード変換はkconvモジュールのtosjisメソッドで実行しています。

app/views/messages/export.csv.ruby
require 'kconv'
require 'csv'

header = ['id', 'メッセージ本文']
CSV.generate("", :headers => header, :write_headers => true) do |csv|
  @messages.each do |message|
    csv << [message['id'], message['text']]
  end
  # データが無い場合、headerが入らないので空データ追加
  csv << [] if csv.lineno == 0
end.tosjis

これで想定通りのSJIS形式のCSVダウンロード機能が完成するのですが、想定外の動作が起きていました。

半角カナが全角へ変換されてしまう

冒頭で紹介した通り、kconvモジュールのtosjisメソッドを使ったSJISへの変換では半角カナが全角へと変換されてしまいます。これについてはリファレンスにしっかり書いてありました。

tosjis(str) -> String[permalink][rdoc]
文字列 str のエンコーディングを shift_jis に変換して返します。

このメソッドは MIME エンコードされた文字列を展開し、いわゆる半角カナを全角に変換します。
https://docs.ruby-lang.org/ja/latest/class/Kconv.html#M_TOSJIS

対策はnkf

半角カナを全角へと変換することを防ぎたい場合は、nkfというライブラリを使えば良いです。ちなみに先ほどのkconvnkfのラッパークラスであり、kconvの処理中でnkfによる変換処理が行われています。

nkfkconvとは異なりオプションで様々な変換形式を指定することができるようになっています。

require 'nkf'

NKF.nkf('-s', str) # 変換元の文字コードを自動判定し、sjisへ変換
NKF.nkf('-w', str) # 変換元の文字コードを自動判定し、utf-8へ変換

リファレンス:module NKF

それを踏まえて、今回のケースではnkfライブラリを用いて半角カナから全角への変換を抑止するオプションである-xを指定した変換を行えばOKです。

require 'nkf'
require 'csv'

header = ['id', 'メッセージ本文']
csv_str = CSV.generate("", :headers => header, :write_headers => true) do |csv|
  @messages.each do |message|
    csv << [message['id'], message['text']]
  end
  # データが無い場合、headerが入らないので空データ追加
  csv << [] if csv.lineno == 0
end

# NKFで変換(-x:半角カナを全角の変換を抑止)
NKF.nkf('--sjis -x', csv_str)

nkfkconvの使い分けは???

以上nkfkconvの使い方を見てましたが、二種類の文字コード変換ライブラリはどのように使えば良いのか疑問に思うかもしれません(自分は思いました)

主観的意見ですが、2つのメリット/デメリットは以下の点だと思います

kconv

kconvのメリット

  • nkfのラッパークラスということもあり、変換メソッドがrubyの特徴である読みやすい、可読性のある書き方である(例:tosjis)

kconvのデメリット

  • 半角カナ→全角へ変換されてしまうように、細かい変換について痒いところに手が届かない

nkf

nkfのメリット

  • 様々なオプション指定によって多様な変換方法を実現
  • 明示的に変換指定を行うことができ、意図しない変換を防ぐことができる

nkfのデメリット

  • オプション指定が複雑で覚えづらく、分かりづらい

ざっとこんなもんだと思います。

以上を踏まえて、個人的には 文字コード変換はnkfを使った方が良い と感じました。その理由は意図しない変換を防げる点 です。やはり、今回のように意図しない変換によって、サービスを使うユーザーに悪影響を与えてしまう点は防ぎたいですしね。

2018/10/07_追記箇所(↓↓↓)

string:encodeを使う

@scivolaさんより頂いたコメントもとに、素直にstring::encodeを使えば、目的が果たせること、記述量がシンプルになりより良いことがわかりました。ありがとうございました。

require 'csv'

header = ['id', 'メッセージ本文']
CSV.generate("", :headers => header, :write_headers => true) do |csv|
  @messages.each do |message|
    csv << [message['id'], message['text']]
  end
  csv << [] if csv.lineno == 0
# encodeで`UTF-8`から`Shift_JIS`へ変換
end.encode("Shift_JIS", "UTF-8")

また、string::encodeは、nkfよりも以下の点でより良いと感じました。

  • Unicode系の文字コードを CP932(Windows 31J)といった変換をする際に、変換できない文字をどうするか考える必要がある。
  • nkfは変換できない場合に当該文字が消えてしまうため、事前に適切な変換指定が必要
  • 一方、string:encodeは変換できない場合「?」に変換されること、変換できない場合の動作をオプションで指定できること、変換オプションが多様である。

CP932(Windows 31J)へ変換できない文字に対する動作を試してみると、以下のようになります。デフォルトでは例外送出で、オプション指定で変換できない場合の動作を指定できます。便利ですね!!!

> irb
irb(main):002:0> "\u{301C}"
=> "〜"

# 変換できない場合のデフォルトの動作
irb(main):003:0> "\u{301C}".encode(Encoding::Windows_31J)
Traceback (most recent call last):
        3: from /Users/shinji.uyama/.anyenv/envs/rbenv/versions/2.5.1/bin/irb:11:in `<main>'
        2: from (irb):3
        1: from (irb):3:in `encode'
Encoding::UndefinedConversionError (U+301C from UTF-8 to Windows-31J)

# 変換できない場合置き換えを行う指定を行なった場合(undefオプション)
irb(main):004:0> "\u{301C}".encode(Encoding::Windows_31J, undef: :replace)
=> "?"

# 変換できない場合、指定文字へと置き換えを行う指定を行なった場合(undef+replaceオプション)
irb(main):006:0> "\u{301C}".encode(Encoding::Windows_31J, undef: :replace, replace: "UNDEF_CHARCTER!!!!")
=> "UNDEF_CHARCTER!!!!"

追記箇所(↑↑↑)

最後に

文字コードは前職の銀行システムを作っていた時も非常に悩まされた点ですが、IT業界に関わる上で文字コードは避けて通れない部分だと改めて感じました。今後とも理解を深めていきたいです。

参考文献

標準添付ライブラリ紹介 【第 3 回】 Kconv/NKF/Iconv

instance method String#encode

String#encodeが変換できそうで変換できない文字