以下の内容はhttps://uepon.hatenadiary.com/entry/2025/10/15/190529より取得しました。


Mac製ZIPファイルの文字化け問題(解)

先日、Mac製ZIPファイルの文字化け問題について書きましたが、その後さらに調べていたところ、根本的な解決方法につながる情報を見つけました。

その結果、前回の記事で書いた「WindowsUTF-8に対応していない」という理解は誤りだったことが判明しました🙇実際には、WindowsUTF-8対応した実装されていました。

文面が「Windows、まだShift_JISに依存しているのかよ~」というものになっていたかもしれません。謹んでお詫びいたします。マジスマン🙇

済まないと思うだけでは良くないので、メモとして残しておきます。

前回のおさらい

前回のブログでは、MacUTF-8(NFD)で作成されたZIPファイルがWindowsUbuntuで文字化けする問題について、以下のような解決方法を紹介しました。

uepon.hatenadiary.com

WindowsUTF-8に対応していないから」とシャーない感じで理解していましたが、誤解でした。正確には「Mac側がZIP仕様のEFSフラグを設定していないため」です。

新たな発見:EFSフラグの存在

以下のQiita記事を見つけました。

Windows エクスプローラー、7-Zip でファイル名を UTF-8 エンコードした ZIP ファイルを文字化けせずに解凍するためには ZIP ファイル内で EFS が有効にされている必要がある?

qiita.com

この記事を読んで、重要な事実がわかりました。

実は、WindowsエクスプローラーはちゃんとUTF-8に対応しているというのです!

参考エントリーにあるEFSフラグとは

ZIP仕様では、UTF-8を使う場合に設定すべきフラグが定義されています。それがEFS(Language encoding flag)です。

  • ビット11: Language encoding flag (EFS)
  • : 0x0800 (16進数)
  • 効果: このフラグが有効な場合、ファイル名とコメントはUTF-8エンコードされていると解釈される

Windowsエクスプローラーは、ZIP仕様通りにEFSフラグを見てUTF-8として処理しているのです。つまり、Windowsは仕様に対応している!

真の問題:Mac側がEFSフラグを設定していない

問題は、MacのZIPツールがUTF-8でファイル名をエンコードしているのに、EFSフラグを設定していないことでした。

これでは、Windowsエクスプローラーは「ZIPファイル内にEFSフラグが無い → UTF-8ではない → Shift_JISだろう」と(仕様に従って)判断してしまうため、文字化けが発生していたのです。

Mac側が仕様通りにEFSフラグを設定してくれていれば、問題は起きなかったということになります。

解決策:EFSフラグを有効化するプログラムを作成

この情報を元に、既存のZIPファイルのEFSフラグを有効化するPythonプログラムを準備しました。

プログラムの概要

GitHubにおいておきます。

github.com

fix_zip_utf8.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ZIPファイルのEFS (Language encoding flag)を有効化するスクリプト.

UTF-8エンコードされたファイル名が文字化けしないようにフラグを設定します。
Windowsエクスプローラーや7-Zipで日本語ファイル名を正しく表示するために使用します。
"""

import struct
import sys
import argparse
from pathlib import Path
from typing import List, Tuple, Optional, Union


class ZipEFSEnabler:
    """ZIPファイルのEFSフラグを有効化するクラス.
    
    ZIP仕様(APPNOTE.TXT)で定義されているLanguage encoding flag (EFS)を
    ローカルファイルヘッダとセントラルディレクトリヘッダに設定します。
    
    Attributes:
        LOCAL_FILE_HEADER_SIGNATURE: ローカルファイルヘッダのシグネチャ (PK\x03\x04)
        CENTRAL_DIRECTORY_SIGNATURE: セントラルディレクトリヘッダのシグネチャ (PK\x01\x02)
        END_OF_CENTRAL_DIR_SIGNATURE: セントラルディレクトリ終端のシグネチャ (PK\x05\x06)
        EFS_FLAG: EFSフラグのビットマスク (0x0800 = ビット11)
        zip_path: 処理対象のZIPファイルパス
    """
    
    # ZIPファイルの各シグネチャ
    LOCAL_FILE_HEADER_SIGNATURE: bytes = b'PK\x03\x04'
    CENTRAL_DIRECTORY_SIGNATURE: bytes = b'PK\x01\x02'
    END_OF_CENTRAL_DIR_SIGNATURE: bytes = b'PK\x05\x06'
    
    # EFSフラグのビットマスク (ビット11)
    EFS_FLAG: int = 0x0800
    
    def __init__(self, zip_path: Union[str, Path]) -> None:
        """ZipEFSEnablerを初期化する.
        
        Args:
            zip_path: 処理対象のZIPファイルパス
            
        Raises:
            FileNotFoundError: 指定されたZIPファイルが存在しない場合
        """
        self.zip_path: Path = Path(zip_path)
        if not self.zip_path.exists():
            raise FileNotFoundError(f"ZIPファイルが見つかりません: {zip_path}")
    
    def enable_efs(self, output_path: Optional[Union[str, Path]] = None) -> Tuple[List[Tuple[str, int, int, int]], Path]:
        """ZIPファイルのEFSフラグを有効化する.
        
        ローカルファイルヘッダとセントラルディレクトリヘッダの汎用目的ビットフラグに
        EFSフラグ(ビット11)を設定します。これにより、ファイル名とコメントが
        UTF-8エンコードされていることを示します。
        
        Args:
            output_path: 出力ファイルパス。Noneの場合は元のファイルを上書きします。
            
        Returns:
            変更情報のリストと出力ファイルパスのタプル。
            変更情報は (ヘッダタイプ, オフセット, 元のフラグ, 新しいフラグ) の形式。
            
        Raises:
            IOError: ファイルの読み込みまたは書き込みに失敗した場合
        """
        # ZIPファイルを読み込み
        with open(self.zip_path, 'rb') as f:
            data: bytearray = bytearray(f.read())
        
        # 変更箇所を記録
        modifications: List[Tuple[str, int, int, int]] = []
        
        # ローカルファイルヘッダのフラグを更新
        offset: int = 0
        while offset < len(data) - 4:
            if data[offset:offset+4] == self.LOCAL_FILE_HEADER_SIGNATURE:
                flag_offset: int = offset + 6
                current_flag: int = struct.unpack('<H', data[flag_offset:flag_offset+2])[0]
                new_flag: int = current_flag | self.EFS_FLAG
                
                # フラグが変更される場合のみ記録
                if current_flag != new_flag:
                    struct.pack_into('<H', data, flag_offset, new_flag)
                    modifications.append(('ローカルファイルヘッダ', offset, current_flag, new_flag))
                
                # 次のヘッダへスキップ(最小サイズ分)
                offset += 30
            else:
                offset += 1
        
        # セントラルディレクトリヘッダのフラグを更新
        offset = 0
        while offset < len(data) - 4:
            if data[offset:offset+4] == self.CENTRAL_DIRECTORY_SIGNATURE:
                flag_offset = offset + 8
                current_flag = struct.unpack('<H', data[flag_offset:flag_offset+2])[0]
                new_flag = current_flag | self.EFS_FLAG
                
                # フラグが変更される場合のみ記録
                if current_flag != new_flag:
                    struct.pack_into('<H', data, flag_offset, new_flag)
                    modifications.append(('セントラルディレクトリヘッダ', offset, current_flag, new_flag))
                
                # 次のヘッダへスキップ(最小サイズ分)
                offset += 46
            else:
                offset += 1
        
        # 出力パスの決定
        if output_path is None:
            output_path = self.zip_path
        else:
            output_path = Path(output_path)
        
        # ファイルに書き込み
        with open(output_path, 'wb') as f:
            f.write(data)
        
        return modifications, output_path


def main() -> None:
    """メイン処理を実行する.
    
    コマンドライン引数を解析し、ZIPファイルのEFSフラグを有効化します。
    デフォルトでは新しいファイルを作成し、--overwriteオプションで
    元のファイルを上書きすることができます。
    
    Raises:
        SystemExit: エラー発生時または処理中断時
    """
    parser = argparse.ArgumentParser(
        description='ZIPファイルのEFSフラグを有効化して、UTF-8ファイル名の文字化けを防ぎます。',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='''
使用例:
  # 新しいファイルを作成(デフォルト: 元のファイル名_fixed.zip)
  python fix_zip_utf8.py input.zip

  # 出力ファイル名を指定
  python fix_zip_utf8.py input.zip -o output.zip

  # 詳細な変更情報を表示
  python fix_zip_utf8.py input.zip -v

  # 元のファイルを上書き(注意: バックアップを取ることを推奨)
  python fix_zip_utf8.py input.zip --overwrite
        '''
    )
    
    parser.add_argument('input', help='入力ZIPファイルパス')
    parser.add_argument('-o', '--output', help='出力ZIPファイルパス(指定しない場合は自動生成)')
    parser.add_argument('--overwrite', action='store_true', 
                       help='元のファイルを上書きする(デフォルトでは新しいファイルを作成)')
    parser.add_argument('-v', '--verbose', action='store_true',
                       help='詳細な変更情報を表示する')
    
    args = parser.parse_args()
    
    try:
        input_path: Path = Path(args.input)
        
        # 出力パスの決定
        if args.overwrite:
            output_path: Path = input_path
            print("⚠ 警告: 元のファイルを上書きします")
        elif args.output:
            output_path = Path(args.output)
        else:
            # デフォルト: 元のファイル名に _fixed を付ける
            stem: str = input_path.stem
            suffix: str = input_path.suffix
            output_path = input_path.parent / f"{stem}_fixed{suffix}"
        
        # 出力ファイルが既に存在する場合の確認(上書きモード以外)
        if not args.overwrite and output_path.exists():
            response: str = input(f"ファイル '{output_path}' は既に存在します。上書きしますか? [y/N]: ")
            if response.lower() not in ['y', 'yes']:
                print("処理を中止しました。")
                sys.exit(0)
        
        enabler: ZipEFSEnabler = ZipEFSEnabler(input_path)
        modifications: List[Tuple[str, int, int, int]]
        modifications, output_path = enabler.enable_efs(output_path)
        
        print(f"✓ EFSフラグを有効化しました: {output_path}")
        print()
        
        if modifications:
            # 変更箇所をカテゴリごとに集計
            local_count: int = sum(1 for m in modifications if m[0] == 'ローカルファイルヘッダ')
            central_count: int = sum(1 for m in modifications if m[0] == 'セントラルディレクトリヘッダ')
            
            print(f"変更箇所: {len(modifications)}件")
            if local_count > 0:
                print(f"  - ローカルファイルヘッダ: {local_count}件")
            if central_count > 0:
                print(f"  - セントラルディレクトリヘッダ: {central_count}件")
            
            # 詳細モードの場合は個別の変更情報も表示
            if args.verbose:
                print()
                print("詳細:")
                for header_type, offset, old_flag, new_flag in modifications:
                    print(f"  - {header_type} @ 0x{offset:08X}")
                    print(f"    0x{old_flag:04X} → 0x{new_flag:04X}")
        else:
            print("変更箇所: なし(すでにEFSフラグが有効でした)")
        
    except KeyboardInterrupt:
        print("\n処理を中断しました。")
        sys.exit(1)
    except Exception as e:
        print(f"エラー: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()

基本的な使い方

# 新しいファイルを作成(input_fixed.zip)
$ python fix_zip_utf8.py input.zip

# 出力ファイル名を指定
$ python fix_zip_utf8.py input.zip -o output.zip

# 詳細な変更情報を表示
$ python fix_zip_utf8.py input.zip -v

実行例

$ python fix_zip_utf8.py mac_archive.zip
✓ EFSフラグを有効化しました: mac_archive_fixed.zip

変更箇所: 12件
  - ローカルファイルヘッダ: 6件
  - セントラルディレクトリヘッダ: 6件

このプログラムで処理したZIPファイルは、ZIP仕様に準拠した形になるため、Windowsエクスプローラーでも日本語ファイル名が正しく表示されるようになります。

ZIPファイルの技術的な構造の補足

ZIPファイルは、各ファイルごとに以下の2つのヘッダを持っています。

  1. ローカルファイルヘッダ … 各ファイルの実データの直前にある
  2. セントラルディレクトリヘッダ … ZIPファイルの最後にまとめて配置される

そのため、ZIPファイル内のファイル数 × 2 箇所のフラグを変更する必要があります。

フラグの位置

  • ローカルファイルヘッダ: オフセット+6の汎用目的ビットフラグ
  • セントラルディレクトリヘッダ: オフセット+8の汎用目的ビットフラグ

それぞれのフラグに 0x0800 をOR演算で設定します。

参考にしたブログにも書かれているので、詳細はそちらもご確認ください。

おわりに

NKT(長く苦しい戦いだった)

前回は「なぜ文字化けするのか」と「対処療法」について書きましたが、今回は「真の原因」と「根本的な解決方法」を見つけることができました。

そして何より、前回「Windowsに修正してほしい」と書いたのは完全な誤解でしたね。Windowsエクスプローラーもそれに従って正しく実装されていました

誤解して申し訳ございませんでした🙏これ修正される日はくるのかな?

参考リンク

以前書いたエントリ

uepon.hatenadiary.com

Windows エクスプローラー、7-Zip でファイル名を UTF-8 エンコードした ZIP ファイルを文字化けせずに解凍するためには ZIP ファイル内で EFS が有効にされている必要がある?

qiita.com

ZIPファイルの仕様

ZIP仕様書(APPNOTE.TXT)




以上の内容はhttps://uepon.hatenadiary.com/entry/2025/10/15/190529より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14