🤖

RustでCSVでUTF-16

2024/03/05に公開

目的

RustでCSVでUTF-16で作成したいです。
まだ CSV の文字化けで消耗してるの?(Excel で直接開いても文字化けしない CSVファイルを Python3 で作成するスマートな方法)の記事にあるように、Excelで開くCSVはUTF-16で作成したいです。

コード

CSV

crate csvでは以下のようなインターフェースになっています。

 pub fn write_record<I, T>(&mut self, record: I) -> Result<()>
    where
        I: IntoIterator<Item = T>,
        T: AsRef<[u8]>,

なるほど、UTF-16なバイナリを流してやればうまくいきそうです。うまくいきませんでした。作ってみたところバリバリ文字化けしました。バイナリエディターで確認してみたところ、デリミターであるタブがUTF-16になっていませんでした。

CSVのソースを再び見てみます。

pub struct Writer {
    state: WriterState,
    requires_quotes: [bool; 256],
    delimiter: u8,
    term: Terminator,
    style: QuoteStyle,
    quote: u8,
    escape: u8,
    double_quote: bool,
    comment: Option<u8>,
}

残念ながらdelimiter, quote, escapeなど殆どがu8型です。UTF-16では2byteほしいのです。

ファイルコンバート

よってまずはUTF-8で作成した後にできたファイルをUTF-16に変換することにしました。
色々crate探してみたんですが、メモリに全部展開して作成するものしかなくて自前で実装することにしました。

UTF-8 to UTF-16

まず単純な文字列をUTF-16に変換するコードを紹介します。ほしいのはlittle endianなのでto_le_bytesを使います。これの戻りは2byteなのでflat_mapを使って分解しています。

let src = "予定表~①💖ハンカクだ".to_owned();
let dst: Vec<u8> = src.encode_utf16().flat_map(|it| it.to_le_bytes()).collect();

ファイル書き換え

1byteごとに処理を行います。CRLFが来るまでバッファーに保存して、来たら一旦UTF-8の文字列にして、その後でUTF-16に変換しています。利用するメモリは最大行のサイズになりますが、まあ現実的かと思います。

またファイルの先頭にはUTF-16LEで有ることを示すBOMを書き込んでいます。

const BYTE_ORDER_MARK: [u8; 2] = [0xFF, 0xFE];
const CR: u8 = b'\r';
const LF: u8 = b'\n';

fn convert_file<P>(src: P, dst: P) -> Result<(), CsvZipError>
where
    P: AsRef<Path>,
{
    let mut reader = BufReader::new(File::open(src)?);
    let mut writer = BufWriter::new(File::create(dst)?);

    // BOMを書き込む
    writer.write_all(&BYTE_ORDER_MARK)?;

    // 1byteごとに取得
    let mut buf = [0; 1];
    let mut buffer: Vec<u8> = Vec::new();
    let mut cr_flag = false;
    loop {
        match reader.read(&mut buf)? {
            0 => break, // ファイルの最後はCRLFでおわるはず。
            _n => {
                buffer.push(buf[0]);
                if cr_flag {
                    if buf[0] == LF {
                        // CRLFが完成した
                        writer.write_all(&make_bytes(buffer)?)?;
                        buffer = Vec::new();
                        cr_flag = false;
                    } else if buf[0] == CR {
                        // 連続でCRがきた場合はcr_flagは立てたまま
                    } else {
                        // CRの次にCRまたはLFが来ていない場合はCRフラグを落とす
                        cr_flag = false;
                    }
                } else if buf[0] == CR {
                    // CRが来たのでフラグを立てる
                    cr_flag = true;
                }
            }
        }
    }
    writer.flush().map_err(|e| e.into())
}

fn make_bytes(src: Vec<u8>) -> Result<Vec<u8>, CsvZipError> {
    let src = match String::from_utf8(src) {
        Ok(res) => res,
        Err(e) => return Err(CsvZipError::Utf16(e.to_string())),
    };
    let dst: Vec<u8> = src.encode_utf16().flat_map(|it| it.to_le_bytes()).collect();
    Ok(dst)
}

まとめ

CSVとZIPをまとめて管理するライブラリcsv-zip-makerに今回の内容を組み込みました。
以下のテストコードではsummary3がUTF-16になります。

#[cfg(test)]
mod tests {
    use crate::{customize::CsvExcelUtf16Customizer, CsvZipError, CsvZipMaker};

    #[test]
    fn it_works() -> Result<(), CsvZipError> {
        let mut maker = CsvZipMaker::new("test", "summary")?;
        let mut csv_maker = maker.make_csv_maker_for_excel("summary1")?;
        csv_maker.write(&vec!["aaa", "bbb"])?;
        csv_maker.write(&vec!["ccc", "ddd"])?;
        maker.add_csv(&mut csv_maker)?;

        let mut csv_maker = maker.make_csv_maker("summary2")?;
        csv_maker.write(&vec!["111", "222"])?;
        csv_maker.write(&vec!["333", "444"])?;
        maker.add_csv(&mut csv_maker)?;

        let mut csv_maker =
            maker.make_csv_maker_with_customizer("summary3", CsvExcelUtf16Customizer)?;
        csv_maker.write(&vec!["予定表~①\n💖ハンカクだ", "予定表~②💖ハンカクだ"])?;
        csv_maker.write(&vec!["予定表~③💖ハンカクだ", "予定表~④💖ハンカクだ"])?;
        maker.add_csv_utf16(&mut csv_maker)?;

        let path_buf = maker.make_zip_file()?;
        std::fs::copy(path_buf, "test.zip")?;

        Ok(())
    }
}

Discussion