JSZip 等のサードパーティライブラリを使用せず、Web標準の CompressionStream を利用してZIPファイルを生成するための備忘録。
仕様の完全な実装を目指すものではないが、Deflate圧縮を用いた一般的なZIPアーカイブを構築する最小構成をまとめる。
参考: Wikipedia: ZIP (ファイルフォーマット), PKWARE APPNOTE.TXT v6.3.10
ZIPファイルはファイルの末尾からディレクトリ情報を読み取る仕様になっている。全体構造は以下の順序で並ぶ。
各ファイルの実データの前にあるヘッダ。固定長30バイト+可変長(ファイル名等)。
| オフセット | サイズ(Byte) | 説明 | 固定値・設定値の例 |
|---|---|---|---|
| 0 | 4 | シグネチャ | 0x04034b50 ("PK\x03\x04") |
| 4 | 2 | 展開に必要なバージョン | 20 (Deflate要求のため 2.0) |
| 6 | 2 | 汎用目的のビットフラグ | 0x0800 (Bit 11: ファイル名UTF-8) |
| 8 | 2 | 圧縮メソッド | 8 (Deflate) または 0 (Store) |
| 10 | 2 | ファイルの最終更新時間 | MS-DOS Time形式 |
| 12 | 2 | ファイルの最終更新日付 | MS-DOS Date形式 |
| 14 | 4 | CRC-32 | 非圧縮データのCRC32ハッシュ |
| 18 | 4 | 圧縮サイズ | 圧縮後のバイト数 |
| 22 | 4 | 非圧縮サイズ | 元のバイト数 |
| 26 | 2 | ファイル名の長さ (n) | UTF-8エンコード後のバイト数 |
| 28 | 2 | 拡張フィールドの長さ (m) | 0 |
| 30 | n | ファイル名 | UTF-8文字列 |
| 30+n | m | 拡張フィールド | (なし) |
※LFHの直後に、圧縮された実データが続く。
ファイル群の後に置かれる目次情報。固定長46バイト+可変長。
| オフセット | サイズ(Byte) | 説明 | 固定値・設定値の例 |
|---|---|---|---|
| 0 | 4 | シグネチャ | 0x02014b50 ("PK\x01\x02") |
| 4 | 2 | 作成元のバージョン | 20 |
| 6 | 2 | 展開に必要なバージョン | 20 |
| 8 | 2 | 汎用目的のビットフラグ | 0x0800 |
| 10 | 2 | 圧縮メソッド | 8 (Deflate) |
| 12 | 4 | 最終更新日時 | LFHと同じ(Date/Time) |
| 16 | 4 | CRC-32 | LFHと同じ |
| 20 | 4 | 圧縮サイズ | LFHと同じ |
| 24 | 4 | 非圧縮サイズ | LFHと同じ |
| 28 | 2 | ファイル名の長さ (n) | LFHと同じ |
| 30 | 2 | 拡張フィールドの長さ | 0 |
| 32 | 2 | ファイルコメントの長さ | 0 |
| 34 | 2 | ディスク番号開始位置 | 0 |
| 36 | 2 | 内部ファイル属性 | 0 |
| 38 | 4 | 外部ファイル属性 | 0 |
| 42 | 4 | LFHの相対オフセット | アーカイブ先頭からこのLFHまでのバイト数 |
| 46 | n | ファイル名 | LFHと同じ |
ファイルの末尾に配置される。固定長22バイト+可変長。
| オフセット | サイズ(Byte) | 説明 | 固定値・設定値の例 |
|---|---|---|---|
| 0 | 4 | シグネチャ | 0x06054b50 ("PK\x05\x06") |
| 4 | 2 | このディスクの番号 | 0 |
| 6 | 2 | CDが開始するディスク番号 | 0 |
| 8 | 2 | このディスク上のCDレコード数 | ファイル総数 |
| 10 | 2 | CDレコードの総数 | ファイル総数 |
| 12 | 4 | CDの合計サイズ | 全CDのバイト数の合計 |
| 16 | 4 | CDの開始オフセット | アーカイブ先頭から最初のCDまでのバイト数 |
| 20 | 2 | ZIPファイルコメントの長さ (c) | 0 |
| 22 | c | ZIPファイルコメント | (なし) |
ZIP仕様では、データ圧縮アルゴリズム(メソッド8)として「生のDeflate (Raw Deflate)」を要求する。ヘッダやチェックサムを含めてはならない。
JavaScript の new CompressionStream("deflate") はZLIB形式を出力するため、ZIPにそのまま組み込むと解凍時にエラーとなる。
対策として、生のDeflateを出力する "deflate-raw" を使用する。未サポートのブラウザに備え、"deflate" で圧縮後に手動でヘッダ(2バイト)とフッタ(4バイト)を取り除くフォールバック処理を入れる。
/**
* 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' });
}
}
上記ロジックを利用したブラウザ上でのZIP生成デモ。追加したファイル群はメモリ上で圧縮・結合され、Blob URL経由でダウンロードされる。
歴史的に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バイト列に変換
0x7075: Unicode Path Extra Field) を用いる代替手法も仕様書 (Appendix D) に記載されているが、現代の環境では Bit 11 を用いるのが最もシンプルかつ標準的。
本実装は32ビットベースの標準ZIPフォーマットに準拠している。LFH や CD におけるサイズやオフセットのフィールドは 4バイト(32ビット) で定義されているため、扱える最大サイズは 約4GB (0xFFFFFFFF バイト) に制限される。
これを超える巨大なデータを扱う場合は、ZIP64 拡張フォーマットを使用する必要がある。ZIP64の基本要件は以下の通り。
0xFFFFFFFF を設定する。0x0001) に格納する。ZIP64 End of Central Directory Record および ZIP64 End of Central Directory Locator の2つの専用レコードを配置する。
なお、ブラウザ上のJSで数GBのZIPを生成する際、すべてを一度メモリ (ArrayBuffer / Blob) に保持するアプローチはメモリ不足によるクラッシュを引き起こす可能性が高い。ZIP64を実装する場合は、FileSystemWritableFileStream 等を活用し、チャンクごとにディスクへ書き込むストリーミングアーキテクチャへの変更が推奨される。