🦔

Laravel Excelを使う際のエンコーディング問題

2021/08/09に公開

とあるプロジェクトの話ですが、データをCSVファイルに出力する必要があります。Laravelを使っていたので、Laravel Excelとのパッケージを使うことにしました。使い方について公式ドキュメントに詳しく書かれているのでここは割愛します。

日本語データを扱う時に注意しないといけないのがエンコーディングの問題です。通常ではUTF-8でテキストデータをエンコードしているので、CSVとかを出力する際に、エンコーディングがデフォルトとしてUTF-8となっています。

これは何が問題かというと、このデータを日本語のWindows OSでエクセルで開くと、文字化けとなるからです。原因は簡単で、日本語のエクセルはデフォルトとしてSHIFT_JIS(SJIS)とのエンコーディングでファイルを開こうとしていて、UTF-8でエンコードされたデータを読み込むと化てしまいます。

そのため、この問題の解決方向は主に2つ、1)ファイルを開く側でなんとかするか、2)ファイルを作る側で開けるようにするかの2択です。

解決方向1に関しては、「エクセル CSV 文字化け」で検索すると色々と解決法が載っていますが、よく見かけるのがメモ帳で開いてエンコードを変えて別のファイルとして保存する、とのやり方でした。他にもエクセルのデータ取り込み機能で、直接エクセルで開かないやり方があります。ただ何もプログラマーにとってはなんの解決にもなっていなく、使用者の手間を増やすだけのやり方となります。

エンコーディングをSJISに変える

解決方向2なら、真っ先に考えられるのは、出力する際にエンコーディングをSJISに変えることでしょう。ここはphpのmb_convert_encoding方法で簡単に実現可能。Laravel Excelのコンフィグレベルでデフォルトのエンコーティングを変更することも可能ですが、次の節で紹介します。

$path = 'folder/csvfile.csv';
// Laravel Excelで作ったエキスポートオブジェクト
$data = new ItemExport($items); 
// ItemExportクラスにpublic変数writerTypeを作るか、直接Maatwebsite\Excel\Excel\CSV定数を使うか、rawメソッドの2番目の引数は必須
$content = Excel::raw($data, $data->writerType); 
$content = mb_convert_encoding($content, 'SJIS', 'auto');
Storage::disk('local')->put($path, $content);

これで出力されたファイルは、SJISのエンコーディングとなり、エクセルでは正常に開けます。

ただこのやり方では欠点があります。というのは、UTF-8を使う場面が多く、これだと別のシステムに読み込みする際もう一度UTF-8に戻さなければ今度は読み込みの際に文字化けになってしまいます。

BOM付きのCSVファイルを作る

ここで肝心なところは、エクセルは別にUTF-8を認識できないわけではなく、ただ日本語版ではデフォルトとしてSJISを使っているだけのこと。つまり、なんらかの方法でエクセルにこのファイルをUTF-8で読み込んでください、を伝えれば良い。

その方法とはBOM - Byte Order Markとなります。

通称BOM(ボム)といわれるUnicodeの符号化形式で符号化したテキストの先頭につける数バイトのデータのことである。このデータを元にUnicodeで符号化されていることおよび符号化の種類の判別に使用する。
--- wikipedia先生より

HTTPリクエストを送る時に、headerも不可欠で、その中にapplication/jsonとかよく内容のフォーマットを指定しています。それと似ているもので、使っているエンコーディングを明言することで、読み取る側が適切に対応できると。つまり、出力されたファイルにこのBOMをはっきりとつけておけば、問題は解決できるはず。

現在使っているExcelの処理パッケージLaravel Excelでは対応する設定があり、デフォルトがオフになっています。一度設定ファイルをpublishしてから、app/configフォルダーに変更できます。

php artisan vendor:publish --provider="Maatwebsite\Excel\ExcelServiceProvider"

その中からuse_bomの設定項目を探します。ちなみにこのファイルでデフォルトのエンコーディング設定を変更することが可能ですが、システム全体に干渉・影響が出るかは不明・検証する余裕がないので自分はUTF-8のままにしています。

        'csv'                    => [
            'delimiter'              => ',',
            'enclosure'              => '"',
            'line_ending'            => PHP_EOL,
            'use_bom'                => false, // ここをtrueに!
            'include_separator_line' => false,
            'excel_compatibility'    => false,
        ],

これで問題は解決できました。出力されたファイルにLaravel Excelのデフォルトエンコーディング(UTF-8)がBOMとしてファイルに付けられます。これでエクセルが開くときにも文字化けになりません。。

読み込みする時のエンコーディング変換

すでに問題は丸く収まると言いたいところですが、その次の日に、同僚からBOM付きのファイルも文字化けになるとの報告がありました。面白いことに、自分のpcのエクセルで開くとなんの問題もありません(同じく会社のPCなのでよく原因がわかりません)。それで仕方がなく、UTF-8 BOM付きを諦めて、出力エンコーディングをSJISに変えました。

ただ先ほども言いましたが、これだと読み込みの際に文字化けになる可能性があるため(試した結果は期待通りに文字化けしていた)、念のため、自分のシステムに処理できるように、読み込みする際のエンコーディング変換も実装しました。3番目の関数エンコーディング変換とは関係ないですが、一応今回のデータ抽出処理の一部として貼っておきます。

// ファイルパスを読み取り、[line1 => [col1, col2,...], line2 => [col1, col2, ...],]の形でリターン
function parseCSVContent(String $file_path)
{
    // 読み込みデータをUTF-8へ変換
    $content = file_get_contents($file_path);
    $converted = convertToUTF8($content);
    // 改行毎に分けたい時は'\r\n'とかではなく、PHP_EOLでシステム指定のEOL記号が適応
    $lines = explode(PHP_EOL, $converted);
    return array_map('str_getcsv', array_filter($lines));
}

function convertToUTF8(String $content)
{
    // config('const.ENCODING') = ["UTF-8","ASCII","JIS","SJIS","EUC-JP"]
    $encoding = mb_detect_encoding($content, config('const.ENCODING'));
    if ($encoding !== 'UTF-8') {
        $content = mb_convert_encoding($content, 'UTF-8', $encoding);
    }
    return $content;
}

// 任意のコラムのデータを抽出する
function extractColumnData(String $file_path, String $column)
{
    $csv_data = parseCSVContent($file_path);
    $items = array_slice($csv_data, 1); // 一行目の項目名を除外
    if (empty($items))  return [];

    //抽出したいコラムのインデックス抽出、見つからない場合は解析失敗で空配列リターン
    $head_row = $csv_data[0];
    $index = array_search($column, $head_row);
    if ($index === false) return [];

    //目標コラムのデータを配列に抽出
    $data_list = array_map(function ($item) use ($index) {
        return $item[$index];
    }, $items);

    // 空文字列のデータを除外
    return array_filter($data_list);
}

mb_detect_encodingを使う時に、autoで試してみましたが、うまく認識できませんでした。それで一応エンコーディングのリストを作っておこうと。そのリスト(完全版はここ)に存在するエンコーディングであれば機能できるはず。

これでようやく文字化けの文句がなくなりました。やれやれですね。

ただ結局、UTF-8 BOM付きのファイルも同僚のpcで文字化けになる理由は全くわかりませんでした。何か手かがりのある方がいらっしゃればぜひご教示願います。

Discussion