10 February 2024

BOM(Byte Order Mark)とは

CSVファイルを他人から受け取るときに、それがExcelで作られたUTF-8でエンコードされたCSVファイルの場合、BOMと呼ばれるデータがファイルの冒頭3バイトについていることがある。 これがヘッダー検査のときに悪さをしてバリデーションに引っかかって読み込めないということがよくある。 逆に、システムからUTF-8のCSVファイルを出力したが、BOMを付けないとExcel側で文字化けするということもある。

ChatGPTによるBOMの解説

BOM(Byte Order Mark)は、テキストファイルの先頭に配置される特定のバイトシーケンスで、ファイルのエンコーディング形式と、特にエンコーディングが複数のバイトを使用する場合のバイト順(エンディアン)を示します。BOMは、Unicodeテキストファイルを識別するために使われ、特にUTF-16とUTF-32エンコーディング形式ではバイト順を区別するのに役立ちます。

UTF-8の文脈では、BOMはEF BB BFというバイトシーケンスで表され、UTF-8エンコードされたテキストファイルの先頭にオプションで置かれることがあります。UTF-8では、バイトの順序が問題になることはありませんが、BOMを使用すると、ファイルがUTF-8でエンコードされていることを明示的に示すことができます。

BOM付きファイルを出力する

Common LispでBOM付きファイルを出力するならば、with-open-fileの冒頭でEF BB BFの3バイトをくっつけるマクロを定義すればよい。

(defmacro with-output-file-with-bom ((file-stream file) &body body)
  `(progn
     (with-open-file (,file-stream ,file :direction :output :if-exists :supersede
                                     :element-type '(unsigned-byte 8))
       (write-sequence (make-array 3 :element-type '(unsigned-byte 8)
                                     :initial-contents '(#xEF #xBB #xBF))
                       ,file-stream))
     (with-open-file (,file-stream ,file :direction :output :if-exists :append)
       ,@body)))

使用例

(ql:quickload '(:fare-csv :alexandria))

(with-output-file-with-bom (f "/tmp/with-bom.csv")
  (fare-csv:write-csv-lines '(("id" "val")
                              (1 "foo"))
                            f))

;; 比較対象に普通のBOMなしのファイルを出力しておく
(alexandria:with-output-to-file (f "/tmp/without-bom.csv")
  (fare-csv:write-csv-lines '(("id" "val")
                              (1 "foo"))
                            f))

出力されたファイルを読み出してみると、一見同じ文字列だが、インスペクトしてみると冒頭に #\ZERO_WIDTH_NO-BREAK_SPACE が付いていることが分かる。 当然equalは失敗する。

(defparameter *id-with-bom*
  (with-open-file (f "/tmp/with-bom.csv")
    (first (fare-csv:read-csv-line f))))

#|
=> "id"

#<(SIMPLE-ARRAY CHARACTER (3)) {1014FE473F}>
--------------------
Dimensions: (3)
Element type: CHARACTER
Total size: 3
Adjustable: NIL
Fill pointer: NIL
Contents:
0: #\ZERO_WIDTH_NO-BREAK_SPACE
1: #\i
2: #\d
|#

(defparameter *id-without-bom*
  (with-open-file (f "/tmp/without-bom.csv")
    (first (fare-csv:read-csv-line f))))

#|
=> "id"

#<(SIMPLE-ARRAY CHARACTER (2)) {1015020B8F}>
--------------------
Dimensions: (2)
Element type: CHARACTER
Total size: 2
Adjustable: NIL
Fill pointer: NIL
Contents:
0: #\i
1: #\d
|#

(equal *id-with-bom* *id-without-bom*)
;; => NIL

BOMを取り除いて読み込む

BOM付きのファイルから読み出すときは、最初にBOM付きかどうかをチェックした上で、そのままストリームを開くか、冒頭3バイトを削った上で開くマクロが必要。

BOM付きファイルかどうは以下のような関数で判定できる(後にマクロ定義内で使うのでeval-whenでコンパイル時に評価されるようにしておく)

(eval-when (:compile-toplevel :load-toplevel :execute)
  (defun has-bom-p (file)
    (with-open-file (stream file
                            :direction :input
                            :element-type '(unsigned-byte 8))
      (let ((b1 (read-byte stream nil nil))
            (b2 (read-byte stream nil nil))
            (b3 (read-byte stream nil nil)))
        (and b1 b2 b3 (= b1 #xEF) (= b2 #xBB) (= b3 #xBF))))))

with-open-fileでは、途中でファイル読み込みのモードをバイナリモードから文字モードへ切り替えるということはできないので、2回with-open-fileすることになる。 BOM部分がどの文字オブジェクトになるかは処理系依存のようだが、1文字として判定されるのは共通のようなので、BOM付きの場合は冒頭1文字読み飛ばす。

(defmacro with-input-file-with-bom ((file-stream file) &body body)
  (alexandria:with-gensyms (has-bom-p)
    `(let ((,has-bom-p (has-bom-p ,file)))
       (with-open-file (,file-stream ,file
                                     :direction :input
                                     :element-type 'character)
         (when ,has-bom-p
           (read-char ,file-stream))
         ,@body))))

使用例

(equal (with-input-file-with-bom (f "/tmp/without-bom.csv")
         (first (fare-csv:read-csv-line f)))
       (with-input-file-with-bom (f "/tmp/with-bom.csv")
         (first (fare-csv:read-csv-line f))))
;; => T