ZIPファイルの日本語ファイル名が文字化けする問題をNode.jsで解決する(Shift-JIS対応)

ZIPファイルの日本語ファイル名が文字化けする問題をNode.jsで解決する(Shift-JIS対応)

日本のWindows環境で生成されたZIPファイルのファイル名がShift-JISエンコードのため文字化けする問題を、adm-zipのrawEntryNameとiconv-liteを使って解決する方法を紹介します。
2026.08.01

はじめに

外部システムから受け取ったZIPファイルを展開したところ、中のファイル名が ÉñÉvÉäÉPÅ[ÉVÉáÉì.pdf のような文字化けになっていました。

原因はZIPファイル内のファイル名がShift-JISでエンコードされているのに、展開ライブラリがUTF-8として読んでいたのです。

本記事では、adm-zipiconv-lite を使って正しくファイル名をデコードする方法を紹介します。

前提・環境

  • Node.js + TypeScript
  • adm-zip(ZIP操作ライブラリ)
  • iconv-lite(文字エンコーディング変換ライブラリ)

なぜZIPファイル名がShift-JISなのか

ZIPファイルの仕様(APPNOTE.TXT)では、元々ファイル名のエンコーディングはIBM Code Page 437(ASCII拡張)と定義されていました。UTF-8のサポートは2006年のバージョン6.3.0で追加され、General Purpose Bit Flag のビット11で「UTF-8使用」を示す仕組みになっています。

しかし、日本のWindows環境で作成されたZIPファイルは以下の理由でShift-JISのままであることが多いです。

  • 古いバージョンのWindows標準ZIP機能がUTF-8フラグに対応していなかった
  • レガシーシステムが生成するZIPは仕様のアップデートを反映していない
  • 一部のアーカイバは互換性を優先してShift-JISを使い続けている

問題の再現

adm-zip でZIPを展開する通常のコードです。

import AdmZip from "adm-zip";

const zip = new AdmZip("input/report.zip");
zip.extractAllTo("output/report", true);

// output/report/ の中身:
// ÉñÉvÉäÉPÅ[ÉVÉáÉì.pdf    ← 文字化け
// ÉfÅ[É^.csv                ← 文字化け

adm-zip はデフォルトでファイル名をUTF-8として解釈します。Shift-JISのバイト列をUTF-8として読むと、マルチバイト文字が壊れて文字化けになります。

解決: iconv-liteで手動デコード

adm-zip のエントリには rawEntryName プロパティがあり、エンコード前の生バイト列にアクセスできます。これを iconv-lite で正しいエンコーディングに変換します。

file-util.ts
import AdmZip from "adm-zip";
import iconv from "iconv-lite";
import path from "path";
import fs from "fs-extra";

interface UnzipOptions {
  deleteZip?: boolean;
  password?: string;
  encoding?: string;  // "Shift_JIS", "EUC-JP" など
}

export function unzip(
  destFolder: string,
  filePath: string,
  options: UnzipOptions = {}
): string {
  if (!fs.existsSync(filePath)) {
    throw new Error(`ZIP file not found: ${filePath}`);
  }

  const zip = new AdmZip(filePath);
  const basename = path.basename(filePath, ".zip");

  // エンコーディングが指定されている場合、ファイル名を手動デコード
  if (options.encoding) {
    zip.getEntries().forEach((entry) => {
      if (entry.entryName) {
        try {
          const decodedName = iconv.decode(
            entry.rawEntryName,
            options.encoding || ""
          );
          entry.entryName = decodedName;
        } catch (err) {
          console.warn(
            `Could not decode "${entry.entryName}" as ${options.encoding}`
          );
        }
      }
    });
  }

  const outputDir = path.join(destFolder, basename);
  fs.mkdirSync(outputDir, { recursive: true });

  // パスワード付きZIPの処理
  options.password
    ? zip.extractAllTo(outputDir, true, true, options.password)
    : zip.extractAllTo(outputDir, true);

  // ZIPファイルの削除(オプション)
  if (options.deleteZip) {
    try {
      fs.unlinkSync(filePath);  // 同期的に削除
    } catch (err) {
      console.error(`Error deleting ZIP file: ${err}`);
    }
  }

  return outputDir;
}

使い方

// Shift-JIS エンコードのZIPファイルを展開
const outputDir = unzip("output", "input/report.zip", {
  encoding: "Shift_JIS",
  deleteZip: true,
});

// output/report/ の中身:
// アプリケーション.pdf    ← 正しく表示される
// データ.csv              ← 正しく表示される

実装のポイント

rawEntryName vs entryName

entry.entryName    // → ÉñÉvÉäÉPÅ[ÉVÉáÉì.pdf(UTF-8として解釈済み)
entry.rawEntryName // → Buffer(生バイト列)

rawEntryName はBufferオブジェクトで、エンコーディングの解釈が行われていない状態です。これを iconv.decode() に渡すことで、正しい文字列に変換できます。

unlinkSync を使う理由

元のコードでは fs.unlink(非同期版)を使っていましたが、unzip メソッドが return outputDir した後にファイル削除が実行されるため、タイミングによっては後続処理と競合する可能性がありました。

// Before: 非同期で削除 → 関数リターン後に実行される
fs.unlink(filePath, (err) => { /* ... */ });
return outputDir;  // ← 削除完了前にリターン

// After: 同期的に削除 → 削除完了後にリターン
fs.unlinkSync(filePath);
return outputDir;

エンコーディング付きのファイル読み込み

ZIP内のファイルだけでなく、テキストファイルの読み込みでもエンコーディングの指定が必要なことがあります。

file-util.ts
export function readWithEncoding(fileName: string, encoding: string): string {
  const fileBuffer = fs.readFileSync(fileName);
  const fileContent = iconv.decode(fileBuffer, encoding);
  return fileContent;
}
// EUC-JP のCSVファイルを読み込む
const csvContent = readWithEncoding("data.csv", "EUC-JP");

エンコーディングの判別方法

ZIPファイルのファイル名がどのエンコーディングで作られたかは、ZIP仕様上は明示されていません。以下の方法で判別します。

1. General Purpose Bit Flagのビット11を確認

zip.getEntries().forEach((entry) => {
  const flags = entry.header.flags;
  const isUtf8 = (flags & 0x800) !== 0;
  console.log(`${entry.entryName}: UTF-8=${isUtf8}`);
});

ビット11が立っていればUTF-8です。立っていなければ、OS/環境依存のエンコーディングが使われています。

2. 発信元で判断

  • 日本のWindows環境 → ほぼ確実にShift-JIS
  • 日本のUnix/Linux環境 → EUC-JPの可能性あり
  • macOS → UTF-8が多い

3. chardet ライブラリで自動判定

import chardet from "chardet";

const encoding = chardet.detect(entry.rawEntryName);
// → "Shift_JIS" or "UTF-8" or "EUC-JP"

ただし、ファイル名は短いため自動判定の精度は高くありません。発信元がわかっている場合は明示的に指定する方が確実です。

まとめ

  • 日本の企業システムが生成するZIPファイルは、ファイル名がShift-JISエンコードであることが多い
  • adm-ziprawEntryName で生バイト列を取得し、iconv-lite で正しいエンコーディングにデコードする
  • ZIP仕様のGeneral Purpose Bit Flagビット11でUTF-8使用を確認できる
  • このバグはASCII文字だけのファイル名ではテストで検出できない。日本語ファイル名でのテストケースを必ず含めること

この記事をシェアする

関連記事