Make組ブログ

Python、Webアプリや製品・サービス開発についてhirokikyが書きます。

python-docxで生成したWordファイルが「Microsoft OOXML」と判定される問題と解決策

python-docxで生成したWordファイルが正しく判定されない問題

Pythonでpython-docxライブラリを使ってWord文書を生成していたのですが、ファイルは正常に開けるのにMIMEタイプの判定で困った問題に遭遇しました。ファイルアップロード機能を持つWebアプリケーションなどで、ファイルタイプのバリデーションに影響が出る可能性があります。

問題の発見:MIMEタイプが「Microsoft OOXML」になってしまう

python-docxで生成したdocxファイルをLinuxのfileコマンドで確認すると、以下のように判定されてしまいます。

$ file generated.docx
generated.docx: Microsoft OOXML

本来は以下のように「Microsoft Word 2007+」と判定されるべきですが、このフォーマットとして認識されないのです。

$ file correct.docx
correct.docx: Microsoft Word 2007+

この問題は実際にGitHubのIssueでも報告されていました(Issue #545)。MIMEタイプの判定が正確でないと、アップロードされたファイルの検証処理で予期しない動作を引き起こす可能性があります(アップロード先での検証など)。

原因:Zipファイル内のエントリ順序が影響していた

Word文書(docxファイル)の実態は、実はZipアーカイブです。内部には複数のXMLファイルや設定ファイルが格納されています。

ファイルタイプを判定するlibmagic(fileコマンドが使用するライブラリ)は、Zipファイルの先頭から順にエントリを読み取り、特定のパターンに一致するかを確認します。Word 2007+形式として正しく判定されるには、以下の順序でエントリが並んでいる必要があります。

  • [Content_Types].xml
  • _rels/
  • word/

python-docxが生成するファイルは、この順序が保証されていないため、誤った判定結果になってようです。

Zipエントリを並び替えて問題を解決する方法

この問題を解決するには、生成されたdocxファイルのZipエントリを正しい順序に並び替える必要があります。以下に、具体的な実装方法を示します。

解決策:sort_word_2007_mimetype関数の実装

Zipファイルを読み込み、エントリを並び替えて再保存する関数を実装しました。 ややパワープレイではあります。

from io import BytesIO
import zipfile

def sort_word_2007_mimetype(input_bytesio: BytesIO) -> BytesIO:
    """Word 2007+のMIME typeにlibmagic(fileコマンド)が判別するための順序にWordのZipを置き換え
    こうしないと Microsoft OOXML と判定される。
    """
    input_bytesio.seek(0)
    with zipfile.ZipFile(input_bytesio, "r") as zin:
        entries = [(info.filename, zin.read(info.filename), info) for info in zin.infolist()]

    # ルートディレクトリの優先順位
    def root_key(filename: str):
        if filename == "[Content_Types].xml":
            return (0, filename)
        elif filename.startswith("_rels/"):
            return (1, filename)
        elif filename.startswith("word/"):
            return (2, filename)
        elif filename.startswith("docProps/"):
            return (3, filename)
        else:
            return (99, filename)

    # 並び替え:ルートディレクトリ → サブフォルダ内はファイルパス順
    entries.sort(key=lambda x: root_key(x[0]))

    output_bytesio = BytesIO()
    with zipfile.ZipFile(output_bytesio, "w", compression=zipfile.ZIP_DEFLATED) as zout:
        for filename, data, info in entries:
            zi = zipfile.ZipInfo(filename)
            zi.date_time = info.date_time
            zi.compress_type = zipfile.ZIP_DEFLATED
            zi.external_attr = info.external_attr
            zout.writestr(zi, data)

    output_bytesio.seek(0)
    return output_bytesio

実装のポイント

実装の核心は、root_key関数での優先順位の定義とsort関数でのエントリの並び替えです。root_key関数がタプルを返すことで、まずディレクトリレベルでの優先順位が適用され、その後ファイル名でソートされます。また、ZipInfoオブジェクトを保持することで、元のファイルのメタデータ(作成日時、圧縮方式、ファイル属性など)を維持しています。これにより、ファイルの互換性を損なわずに順序だけを修正できます。

この関数を適用した後、fileコマンドで確認すると「Microsoft Word 2007+」と正しく判定されるようになります。 上記のIssueが解決されるまでの回避策ですが、何かのお役に立てると幸いです。

編集:Shodo Boost