次の内容のファイルは CSV ファイルとして適切です。
header1,header2 data1,data2
次の内容のファイルも適切です
header1,header2 data1,data2 data3,data4
この二つのファイルをPHP8.2の mime_content_type で見ると結果に違いが出ます。前者が text/plain になり、後者が text/csv になります。これを示すデモが次です。この違いの原因と対応策を紹介します。
Online PHP editor | output for gl9C2
結論から言うと原因は言語実装の内部にあり(実装についての仕様は見つけられませんでした)、CSVファイルであるか否かのバリデーションはアプリケーションの中で行うのが得策です。MIME Content-type はテキストファイルとCSVファイルの両方を受け入れてファイルの中身を精査する形がおすすめというわけです。
原因の説明をします。まず PHP における MIME Content-type の取得方法を説明します。これを知ると text/csv が text/plain に内包されるタイプであることがわかります。MIME Content-type はファイルの種類を表現する情報です。PHPはこの情報をファイルの中身の先頭付近のデータから得ています。もう少し具体的に言うと MIME Content-type の取得は File Signatures と呼ばれるファイルの種類特有で定められているファイルの中身の先頭から始まるバイト列を読み、読み取った File Signatures に応じたファイルの種類を示す文字列を返す、という流れで行われています。ファイルの先頭から\x89\x50\x4e\x47\x0d\x0a\x1a\x0aが読み取れた、これはPNGファイル、みたいな感じです。実際の File Signatures は次リンクにあります。
さてCSVファイルですがこれはその実、単にカンマで値が区切られただけのテキストファイルであり特別なヘッダーを持ちません。このためCSVファイルであるならばそれはテキストファイルでもある、ということになります。例えば次の内容が含まれたファイルがあった場合、その中身だけからテキストファイルかCSVファイルかを判断するのは困難です。これが1×1の表を持つCSVなのか、単にテキストがあるだけなのかわかりません。。拡張子があるならばそちらをヒントにできますが、もしなかったらお手上げです。
浜松太郎
この様に小さなCSVファイルは中身だけではテキストファイルとして扱うべきかCSVファイルとして扱うべきか言語側から判断が付かないことがわかります。実際の PHP の実装においては閾値を設定し、ある一定以上のサイズでCSVの形式を守っているテキストファイルならばそれはCSVファイルである、と判断されています。
PHPの mime_content_type 内部実装ですが、mimetype 取得の奥の奥までいくと file_is_csv という csv であるか否かを判定する関数があります。これをコメント付きにしたコードが次です。
/* CSV ファイルであるかを判断する関数 */
int
file_is_csv(struct magic_set *ms, const struct buffer *b, int looks_text)
{
const unsigned char *uc = CAST(const unsigned char *, b->fbuf); // ファイルの内容へのポインタ
const unsigned char *ue = uc + b->flen; // ファイルの終端へのポインタ
int mime = ms->flags & MAGIC_MIME; // MIMEタイプを取得
/* テキストファイルでなければ0を返す */
if (!looks_text)
return 0;
/* MAGIC_APPLEまたはMAGIC_EXTENSIONのフラグが立っていれば0を返す */
// MAGIC_APPLE, MAGIC_EXTENSION については不明
if ((ms->flags & (MAGIC_APPLE|MAGIC_EXTENSION)) != 0)
return 0;
/* CSV形式でなければ0を返す */
// ここで呼ばれている csv_parse がこの記事の主役
if (!csv_parse(uc, ue))
return 0;
/* エンコーディングがMIME形式なら1を返す */
if (mime == MAGIC_MIME_ENCODING)
return 1;
/* MIME形式が指定されていれば、"text/csv"と出力して1を返す */
if (mime) {
if (file_printf(ms, "text/csv") == -1)
return -1;
return 1;
}
/* それ以外の場合は、"CSV text"と出力して1を返す */
if (file_printf(ms, "CSV text") == -1)
return -1;
return 1;
}
// CSVファイルのパースを行う関数。パースが可能であるか否かの識別で使われている
static int
csv_parse(const unsigned char *uc, const unsigned char *ue)
{
// nf: 現在の行のフィールド数(カンマで区切られたデータの数)
// tf: 最初の行のフィールド数(後続の行とフィールド数を比較するために保存)
// nl: 現在までに解析した行数
size_t nf = 0, tf = 0, nl = 0;
// ucがue(ファイルの終端)に到達するまでループ
while (uc < ue) {
switch (*uc++) {
case '"':
// ダブルクォートのマッチングまで読み飛ばす
uc = eatquote(uc, ue);
break;
case ',':
// カンマが見つかったらフィールド数を増やす
nf++;
break;
case '\n':
// デバッグ用の出力
DPRINTF("%zu %zu %zu\n", nl, nf, tf);
// 改行が見つかったら行数を増やす
nl++;
#if CSV_LINES
// 定義された行数までチェックしたら結果を返す
// PHPソースコード内に埋め込まれた行数は10。どこかで操作できるやも
if (nl == CSV_LINES)
return tf != 0 && tf == nf;
#endif
// 最初の行の場合
if (tf == 0) {
// フィールドがなければCSVではないと判断
if (nf == 0)
return 0;
// フィールド数を保存
tf = nf;
} else if (tf != nf) {
// フィールド数が最初の行と一致しなければCSVではないと判断
return 0;
}
// フィールド数をリセット
nf = 0;
break;
default:
break;
}
}
// ファイルがCSVとして有効である条件:フィールドが存在し、2行以上存在する
return tf && nl > 2;
}
コードにあるように二行より多い、つまり三行以上の表形式のテキストがつ続かないとcsvとして判断されないのです。このためこの実装内容によって二行以内のCSVファイルはテキストファイルとして認識され、mime_content_type を実行すると text/plain が返されます。
改めて結論としてMIME Content-typeでcsvファイルであるか否かを判断しきるのはお勧めできません。mimeではcsv ファイルあるいはテキストファイルであるかまで識別し、アプリケーション内部で細かくバリデーションを行い、エラーのある行やセルの場所などをユーザーに返すなどするレスポンスを作る方が間違いがおきません。