php $_FILES csv 文字化け ファイル名 NFD NFC

_FILES[]に潜むファイル名文字コードの罠

2021-01-23
ブラウザでアップロードしたファイル名がsjis-winのCSVで文字化け。ファイル名の文字コードがwindowsとmacで違うのが原因らしい。
再利用する場合注意が必要って話です。

普段、ブラウザからファイルアップロードしても、オリジナルファイル名は気にした事はまず無い。

先日、業務系webシステムで、以下のような要件があったのだ。
「オリジナルのファイル名もCSVに含めたい」

さて、ファイルをアップする際に $_FILES['fname']['name'] をDBに突っ込む事自体は普通にある事なのでさして気にしてなかったのだが、その文字列をsjis-wn なCSVに含めた際に、見事文字化けが発生!
色々調べてみると、
  • ブラウザに表示する場合は文字化けしない
  • sjis-winにした場合、日本語の濁点が?になる
  • 原因はファイル名の文字コードが違う(NFDとNFC)
らしい。
大手SIerならいざ知らず、最近の職場はwindwos/Mac混合な環境はよくる話。
さて、どうしたものか。。。

htmlにechoした場合、この文字化けは起こらない!

なぜ今まで気がつかなかったかというと、html内で表示するだけではこの文字化けは起こらないのだ。
正確には、文字が微妙におかしいがパッとみただけでは気がつけない。

sjis-winで濁点が?になる

macからアップロードしたファイル名が日本語で濁点を含んでいる場合、UTF-8からsjis-winに単純にmb_convert_kanaしただけだと以下のように文字化けする。
mb_convert_kana ($row['original_filename], 'sjis-win', 'UTF-8' )
元ファイル名:バンクーバのハンバーグがゴージャス.png
文字化け状態:ハ?ンクーハ?のハンハ?ーク?か?コ?ーシ?ャス.png

Macのファイル名をきれいにしてくれるためだけのクラス

サクッとググってみたものの、そもそもMac内でコンバートとしろとか、なんとも微妙な記事ばかりがヒットする。全然解決できないぞ!
さんざん彷徨った挙句に辿り着いたQiitaにその答えを見つける事ができた!
ありがとう!

Macのファイル名(NFD形式)をNFC形式に変換するPHPのロジック
こんな感じで使えます。
<?php
macFileNameNormalizer::normalizeUtf8MacFileName($row['original_filename']);

/**
 * https://qiita.com/Maranello/items/0638b71621c403cdd8c3
 * Macのファイル名をきれいにしてくれるためだけのクラス
 * (濁点/半濁点をNFD形式⇒NFC形式に変換)
 */
class macFileNameNormalizer
{
    /**
     * Macで作成された日本語ファイル名の濁点/半濁点を吸収するためだけのメソッド
     * ファイル名を渡したらきれいにして返してくれる
     * @param string $string
     * @return string
     */
    static public function normalizeUtf8MacFileName($string)
    {
        $newString = '';
        $beforeChar = '';
        //基本的に一文字前の文字を一文字ずつ繋げていくので、文字数よりも一回ループが多い
        for ($i = 0; $i <= mb_strlen($string, 'UTF-8'); $i++) {
            $nowChar = mb_substr($string, $i, 1, 'UTF-8');
            if ($nowChar == hex2bin('e38299')) { //Macの濁点
                $retChar = self::macConvertKana($beforeChar, false);
                $substituteChar = 'e3829b'; //Windowsの全角濁点
                goto convPoint;
            } elseif ($nowChar == hex2bin('e3829a')) { //Macの半濁点
                $retChar = self::macConvertKana($beforeChar, true);
                $substituteChar = 'e3829c'; //Windowsの全角半濁点

                convPoint: //濁点または半濁点があった場合の処理
                if ($retChar) { //前の文字と合体可能の場合
                    $newString .= $retChar;
                    $beforeChar = '';
                } else { //前の文字と合体不可能の場合
                    $newString .= $beforeChar;
                    $beforeChar = hex2bin($substituteChar); //Windowsの全角濁点/半濁点に置換
                }
            } else { //濁点/半濁点以外はそのままスルー
                $newString .= $beforeChar;
                $beforeChar = $nowChar;
            }
        }
        return $newString;
    }

    /**
     * 一文字渡された文字に対し、濁点付き、半濁点付きの文字を返す
     * @param string $char
     * @param boolean $half
     * @return string
     */
    static public function macConvertKana($char, $half = false)
    {
        $retChar = '';
        if ($char) {
            //濁点の対応表
            $fullTable = array(
                    'か' => 'が','き' => 'ぎ','く' => 'ぐ','け' => 'げ','こ' => 'ご',
                    'さ' => 'ざ','し' => 'じ','す' => 'ず','せ' => 'ぜ','そ' => 'ぞ',
                    'た' => 'だ','ち' => 'ぢ','つ' => 'づ','て' => 'で','と' => 'ど',
                    'は' => 'ば','ひ' => 'び','ふ' => 'ぶ','へ' => 'べ','ほ' => 'ぼ',
                    'ゝ' => 'ゞ',
                    'カ' => 'ガ','キ' => 'ギ','ク' => 'グ','ケ' => 'ゲ','コ' => 'ゴ',
                    'サ' => 'ザ','シ' => 'ジ','ス' => 'ズ','セ' => 'ゼ','ソ' => 'ゾ',
                    'タ' => 'ダ','チ' => 'ヂ','ツ' => 'ヅ','テ' => 'デ','ト' => 'ド',
                    'ハ' => 'バ','ヒ' => 'ビ','フ' => 'ブ','ヘ' => 'ベ','ホ' => 'ボ',
                    'ウ' => 'ヴ','ヽ' => 'ヾ',
            );
            //半濁点の対応表
            $halfTable = array(
                    'は' => 'ぱ','ひ' => 'ぴ','ふ' => 'ぷ','へ' => 'ぺ','ほ' => 'ぽ',
                    'ハ' => 'パ','ヒ' => 'ピ','フ' => 'プ','ヘ' => 'ペ','ホ' => 'ポ',
            );
            //どちらの対応表を使うか
            if ($half) {
                $targetArray = $halfTable;
            } else {
                $targetArray = $fullTable;
            }
            //対応表に合致するか
            if (isset($targetArray[$char])) {
                $retChar = $targetArray[$char];
            }
        }
        return $retChar;
    }
}



イラスト:Loose Drawing
イラスト:Loose Drawing

Heading

Donec sed odio dui. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Vestibulum id ligula porta felis euismod semper. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.

View details »