Compression APIを用いたJS単体でのZIP生成

JSZip 等のサードパーティライブラリを使用せず、Web標準の CompressionStream を利用してZIPファイルを生成するための備忘録。
仕様の完全な実装を目指すものではないが、Deflate圧縮を用いた一般的なZIPアーカイブを構築する最小構成をまとめる。

参考: Wikipedia: ZIP (ファイルフォーマット), PKWARE APPNOTE.TXT v6.3.10

1. ZIPファイルの基本構造

ZIPファイルはファイルの末尾からディレクトリ情報を読み取る仕様になっている。全体構造は以下の順序で並ぶ。

  1. Local File Header (LFH) + ファイルデータ (× ファイル数分)
  2. Central Directory Header (CD) (× ファイル数分)
  3. End of Central Directory Record (EOCD) (1つ)

1-1. Local File Header (LFH)

各ファイルの実データの前にあるヘッダ。固定長30バイト+可変長(ファイル名等)。

オフセットサイズ(Byte)説明固定値・設定値の例
04シグネチャ0x04034b50 ("PK\x03\x04")
42展開に必要なバージョン20 (Deflate要求のため 2.0)
62汎用目的のビットフラグ0x0800 (Bit 11: ファイル名UTF-8)
82圧縮メソッド8 (Deflate) または 0 (Store)
102ファイルの最終更新時間MS-DOS Time形式
122ファイルの最終更新日付MS-DOS Date形式
144CRC-32非圧縮データのCRC32ハッシュ
184圧縮サイズ圧縮後のバイト数
224非圧縮サイズ元のバイト数
262ファイル名の長さ (n)UTF-8エンコード後のバイト数
282拡張フィールドの長さ (m)0
30nファイル名UTF-8文字列
30+nm拡張フィールド(なし)

※LFHの直後に、圧縮された実データが続く。

1-2. Central Directory Header (CD)

ファイル群の後に置かれる目次情報。固定長46バイト+可変長。

オフセットサイズ(Byte)説明固定値・設定値の例
04シグネチャ0x02014b50 ("PK\x01\x02")
42作成元のバージョン20
62展開に必要なバージョン20
82汎用目的のビットフラグ0x0800
102圧縮メソッド8 (Deflate)
124最終更新日時LFHと同じ(Date/Time)
164CRC-32LFHと同じ
204圧縮サイズLFHと同じ
244非圧縮サイズLFHと同じ
282ファイル名の長さ (n)LFHと同じ
302拡張フィールドの長さ0
322ファイルコメントの長さ0
342ディスク番号開始位置0
362内部ファイル属性0
384外部ファイル属性0
424LFHの相対オフセットアーカイブ先頭からこのLFHまでのバイト数
46nファイル名LFHと同じ

1-3. End of Central Directory Record (EOCD)

ファイルの末尾に配置される。固定長22バイト+可変長。

オフセットサイズ(Byte)説明固定値・設定値の例
04シグネチャ0x06054b50 ("PK\x05\x06")
42このディスクの番号0
62CDが開始するディスク番号0
82このディスク上のCDレコード数ファイル総数
102CDレコードの総数ファイル総数
124CDの合計サイズ全CDのバイト数の合計
164CDの開始オフセットアーカイブ先頭から最初のCDまでのバイト数
202ZIPファイルコメントの長さ (c)0
22cZIPファイルコメント(なし)

2. Compression API の deflate と deflate-raw

ZIP仕様では、データ圧縮アルゴリズム(メソッド8)として「生のDeflate (Raw Deflate)」を要求する。ヘッダやチェックサムを含めてはならない。

JavaScript の new CompressionStream("deflate") はZLIB形式を出力するため、ZIPにそのまま組み込むと解凍時にエラーとなる。
対策として、生のDeflateを出力する "deflate-raw" を使用する。未サポートのブラウザに備え、"deflate" で圧縮後に手動でヘッダ(2バイト)とフッタ(4バイト)を取り除くフォールバック処理を入れる。

3. 実装コード (JS単体)

/**
 * ZIP生成用クラス (外部依存なし)
 */

// --- ZIPバイナリ構築クラス ---
class ZipWriter {
    static crcTable = (() => {
        // ZIP仕様ではファイルの完全性チェックのためCRC-32が必須 (APPNOTE 4.4.7)
        const table = new Uint32Array(256);
        for (let i = 0; i < 256; i++) {
            let c = i;
            for (let j = 0; j < 8; j++) {
                c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
            }
            table[i] = c;
        }
        return table;
    })();

    constructor() {
        this.files = [];
    }

    // ZIP仕様のCRC-32を計算する
    crc32(uint8array) {
        const crcTable = ZipWriter.crcTable;
        let crc = 0xFFFFFFFF;
        for (let i = 0; i < uint8array.length; i++) {
            crc = crcTable[(crc ^ uint8array[i]) & 0xFF] ^ (crc >>> 8);
        }
        return (crc ^ 0xFFFFFFFF) >>> 0;
    }

    // Raw Deflate を生成する。未対応環境では zlib 形式からヘッダを除去する
    async compressData(uint8array) {
        try {
            // 最近のブラウザは "deflate-raw" (ZLIBヘッダなし) をサポートしている
            const cs = new CompressionStream("deflate-raw");
            const writer = cs.writable.getWriter();
            writer.write(uint8array);
            writer.close();
            const response = new Response(cs.readable);
            const arrayBuffer = await response.arrayBuffer();
            return new Uint8Array(arrayBuffer);
        } catch (e) {
            // フォールバック: "deflate" はZLIBフォーマットを出力する
            const cs = new CompressionStream("deflate");
            const writer = cs.writable.getWriter();
            writer.write(uint8array);
            writer.close();
            const response = new Response(cs.readable);
            const arrayBuffer = await response.arrayBuffer();

            // 先頭2バイト(ZLIBヘッダ)と末尾4バイト(Adler-32)を手動で削る
            return new Uint8Array(arrayBuffer, 2, arrayBuffer.byteLength - 6);
        }
    }

    // ファイルをバッファに追加 (非同期で圧縮を行う)
    async addFile(name, uint8array) {
        // MS-DOS フォーマットの日時を計算 (APPNOTE 4.4.6)
        // 精度は2秒単位、年は1980年起点
        const date = new Date();
        const dosTime = ((date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1)) & 0xFFFF;
        const dosDate = (((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()) & 0xFFFF;

        const crc = this.crc32(uint8array);
        const uncompressedSize = uint8array.length;
        const compressedData = await this.compressData(uint8array);
        const compressedSize = compressedData.length;
        
        // ファイル名をUTF-8バイト列に変換
        const nameBytes = new TextEncoder().encode(name);

        this.files.push({
            nameBytes, compressedData, crc, uncompressedSize,
            compressedSize, dosTime, dosDate, offset: 0 // offsetはgenerate時に計算
        });
    }

    // ZIPのBlobを生成する
    generate() {
        const parts = []; // Blobを構成するUint8Arrayの配列
        let offset = 0;   // アーカイブ先頭からのオフセット(バイト)

        // 【STEP 1】 各ファイルの Local File Header (LFH) と 実データを書き込む
        for (const file of this.files) {
            file.offset = offset; // CDで参照するため、このLFHの開始位置を記録
            
            // LFH固定長(30バイト) + 可変長(ファイル名)
            const lfh = new ArrayBuffer(30 + file.nameBytes.length);
            const view = new DataView(lfh);
            // 引数の `true` はリトルエンディアンを指定
            view.setUint32(0, 0x04034b50, true); // LFH Signature
            view.setUint16(4, 20, true);         // Version needed (Deflate=2.0)
            view.setUint16(6, 0x0800, true);     // General purpose flag (Bit 11: UTF-8)
            view.setUint16(8, 8, true);          // Compression method (8: Deflate)
            view.setUint16(10, file.dosTime, true);
            view.setUint16(12, file.dosDate, true);
            view.setUint32(14, file.crc, true);  // CRC-32
            view.setUint32(18, file.compressedSize, true);
            view.setUint32(22, file.uncompressedSize, true);
            view.setUint16(26, file.nameBytes.length, true); // File name length
            view.setUint16(28, 0, true);         // Extra field length
            
            const lfhBytes = new Uint8Array(lfh);
            lfhBytes.set(file.nameBytes, 30);    // 30バイト目からファイル名をコピー
            
            parts.push(lfhBytes);                // LFHをバッファに追加
            parts.push(file.compressedData);     // 圧縮データをバッファに追加
            
            offset += lfhBytes.length + file.compressedData.length; // オフセットを進める
        }

        const cdOffset = offset; // CDの開始オフセット位置
        let cdSize = 0;

        // 【STEP 2】 Central Directory Header (CD) を書き込む
        for (const file of this.files) {
            // CD固定長(46バイト) + 可変長(ファイル名)
            const cdfh = new ArrayBuffer(46 + file.nameBytes.length);
            const view = new DataView(cdfh);
            view.setUint32(0, 0x02014b50, true); // CD Signature
            view.setUint16(4, 20, true);         // Version made by
            view.setUint16(6, 20, true);         // Version needed to extract
            view.setUint16(8, 0x0800, true);     // Flag (Bit 11: UTF-8)
            view.setUint16(10, 8, true);         // Compression method (8: Deflate)
            view.setUint16(12, file.dosTime, true);
            view.setUint16(14, file.dosDate, true);
            view.setUint32(16, file.crc, true);
            view.setUint32(20, file.compressedSize, true);
            view.setUint32(24, file.uncompressedSize, true);
            view.setUint16(28, file.nameBytes.length, true);
            view.setUint16(30, 0, true);         // Extra field length
            view.setUint16(32, 0, true);         // File comment length
            view.setUint16(34, 0, true);         // Disk number start
            view.setUint16(36, 0, true);         // Internal file attributes
            view.setUint32(38, 0, true);         // External file attributes
            view.setUint32(42, file.offset, true); // Relative offset of LFH

            const cdfhBytes = new Uint8Array(cdfh);
            cdfhBytes.set(file.nameBytes, 46);   // 46バイト目からファイル名をコピー

            parts.push(cdfhBytes);
            cdSize += cdfhBytes.length;
        }

        // 【STEP 3】 End of Central Directory Record (EOCD) を書き込む
        // EOCD固定長(22バイト), コメントはなし
        const eocd = new ArrayBuffer(22);
        const view = new DataView(eocd);
        view.setUint32(0, 0x06054b50, true); // EOCD Signature
        view.setUint16(4, 0, true);          // Number of this disk
        view.setUint16(6, 0, true);          // Disk with start of CD
        view.setUint16(8, this.files.length, true);  // Number of CD entries on this disk
        view.setUint16(10, this.files.length, true); // Total number of CD entries
        view.setUint32(12, cdSize, true);    // Size of the CD
        view.setUint32(16, cdOffset, true);  // Offset of start of CD
        view.setUint16(20, 0, true);         // ZIP file comment length

        parts.push(new Uint8Array(eocd));

        // 完成したバイナリ配列をひとまとめにしてBlob(application/zip)にする
        return new Blob(parts, { type: 'application/zip' });
    }
}

4. 動作テストモジュール

上記ロジックを利用したブラウザ上でのZIP生成デモ。追加したファイル群はメモリ上で圧縮・結合され、Blob URL経由でダウンロードされる。

アーカイブ対象リスト

  • 登録されたファイルはありません

5. 補足

5-1. Unicodeファイル名の対応 (UTF-8フラグ)

歴史的にZIPのファイル名は IBM Code Page 437 やシステムのデフォルトエンコーディング(日本ではShift_JIS等)で保存されることが多く、OS間の受け渡しにおいて文字化けの原因となっていた。
これを解決するため、General Purpose Bit Flag の Bit 11 (0x0800) を設定することで、ファイル名およびコメントが UTF-8 エンコードであることを解凍ソフトへ明示できる仕様が追加されている。

本実装でも以下の通りこの仕様を採用している。

view.setUint16(6, 0x0800, true); // Bit 11 を立ててUTF-8を宣言
const nameBytes = new TextEncoder().encode(name); // ファイル名をUTF-8バイト列に変換

展開時の挙動と文字化け対策

5-2. ZIP64

本実装は32ビットベースの標準ZIPフォーマットに準拠している。LFH や CD におけるサイズやオフセットのフィールドは 4バイト(32ビット) で定義されているため、扱える最大サイズは 約4GB (0xFFFFFFFF バイト) に制限される。

これを超える巨大なデータを扱う場合は、ZIP64 拡張フォーマットを使用する必要がある。ZIP64の基本要件は以下の通り。

なお、ブラウザ上のJSで数GBのZIPを生成する際、すべてを一度メモリ (ArrayBuffer / Blob) に保持するアプローチはメモリ不足によるクラッシュを引き起こす可能性が高い。ZIP64を実装する場合は、FileSystemWritableFileStream 等を活用し、チャンクごとにディスクへ書き込むストリーミングアーキテクチャへの変更が推奨される。