Published on

Azure Document Intelligenceで一部のXLSXだけ失敗した原因と対処

Authors

最近,会社で RAG 環境を構築するときは,azure-search-openai-demo を土台にすることがよくあります.その中で,一部の .xlsx ファイルだけが Azure Document Intelligence で UnsupportedContent になったときの原因と対処を書きます.

要点

  • Azure Document Intelligence は XLSX をサポート しているが,一部の .xlsx では UnsupportedContent が起きた.
  • 失敗していたファイルには xl/embeddings/ が含まれており,埋め込み OLE オブジェクトが残っていた.成功するファイルとの差分はここだった.
  • xl/embeddings/ を検出し,OLEObjects()OLEFormat を持つ不要なオブジェクトを削除してから渡すと,解析が通るようになった.

背景

azure-search-openai-demo を使って RAG 環境を構築する中で,Excel ファイルを Azure Document Intelligence で解析していました.

対象には古い .xls やマクロ付きの .xlsm も含まれていたため,解析前にそれらを .xlsx に変換するスクリプトを挟んでいました.Azure Document Intelligence は XLSX をサポートしているので,対応形式に揃えれば処理できるはずだと考えていました.

ところが,同じ変換スクリプトを通したにもかかわらず,一部の .xlsx だけ解析に失敗しました.azure-search-openai-demo 側では InvalidRequestUnsupportedContent として見えており,最終的なメッセージは次のようなものでした.

Content is not supported: The input content is corrupted or format is invalid.

成功するファイルもあったため,変換処理そのものではなく,変換後の .xlsx の中身に差があるのではないかと考えて調べることにしました.

詳細

.xlsx は ZIP 形式なので,中身を確認できます.失敗するファイルを調べると,xl/embeddings/ が残っていました.

この xl/embeddings/ には,Excel 内の埋め込みオブジェクトが入ります.今回のケースでは,この埋め込み要素を含むファイルだけが Azure Document Intelligence で失敗しており,除去後は解析が通るようになりました.そのため,今回の原因はここだと判断しました.

Excel 側のオブジェクトモデルにも,ワークシート上の OLE オブジェクトを表す OLEObjects や,OLE を持つ Shape で使える OLEFormat があります.OOXML / Open Specifications 側でも,Excel には OLE データ項目の定義があります. 参考:MS-XLSX: oleItem

そこで,変換後の .xlsx をそのまま渡すのではなく,事前に sanitize しました.sanitize_xlsx.py では,次の流れで処理しています.

  1. .xlsx を ZIP として開いて xl/embeddings/ があるか確認する.
  2. 該当するファイルだけ Excel COM で開く.
  3. 各シートの OLEObjects()OLEFormat を持つ Shape を削除する.
  4. 保存して,Azure Document Intelligence に渡す.

掲載しているコードにはバックアップとロールバックの処理も入れていますが,今回の原因切り分けに直接効いたのは xl/embeddings/ の検出と OLE オブジェクトの削除です.

以下が実際に使った sanitize_xlsx.py です.

sanitize_xlsx.py を開く
from __future__ import annotations

import argparse
import shutil
import sys
import time
import zipfile
from pathlib import Path
from typing import List, Tuple

try:
    import win32com.client  # type: ignore
except ImportError:
    win32com = None  # type: ignore


# ============================================================
# Inspection
# ============================================================


def is_valid_xlsx(path: Path) -> bool:
    try:
        with zipfile.ZipFile(path):
            return True
    except Exception:
        return False


def has_embeddings(path: Path) -> bool:
    try:
        with zipfile.ZipFile(path) as z:
            return any(name.startswith("xl/embeddings/") for name in z.namelist())
    except Exception:
        return False


# ============================================================
# Excel Sanitization
# ============================================================


def open_excel():
    excel = win32com.client.Dispatch("Excel.Application")  # type: ignore
    excel.Visible = False
    excel.DisplayAlerts = False
    try:
        excel.AskToUpdateLinks = False
    except Exception:
        pass
    try:
        excel.EnableEvents = False
    except Exception:
        pass
    return excel


def remove_embedded_objects(path: Path) -> Tuple[int, int]:
    deleted_ole = 0
    deleted_shapes = 0

    excel = open_excel()
    try:
        wb = excel.Workbooks.Open(str(path), ReadOnly=False, CorruptLoad=1)
        try:
            for ws in wb.Worksheets:
                # --- OLEObjects ---
                try:
                    count = ws.OLEObjects().Count
                except Exception:
                    count = 0

                for i in range(count, 0, -1):
                    try:
                        ws.OLEObjects(i).Delete()
                        deleted_ole += 1
                    except Exception:
                        pass

                # --- Shapes with OLEFormat ---
                try:
                    shapes = ws.Shapes
                    scount = shapes.Count
                except Exception:
                    scount = 0

                for i in range(scount, 0, -1):
                    try:
                        shape = shapes.Item(i)
                        try:
                            _ = shape.OLEFormat
                            shape.Delete()
                            deleted_shapes += 1
                        except Exception:
                            pass
                    except Exception:
                        pass

            wb.Save()
        finally:
            wb.Close(SaveChanges=True)
    finally:
        excel.Quit()

    return deleted_ole, deleted_shapes


# ============================================================
# File Processing
# ============================================================


def sanitize_file(path: Path, dry_run: bool) -> Tuple[str, str]:
    if not is_valid_xlsx(path):
        return "skipped", "invalid xlsx (zip error)"

    if not has_embeddings(path):
        return "skipped", "no embeddings"

    if dry_run:
        return "dry-run", "DRY-RUN"

    backup_path = path.with_suffix(path.suffix + ".bak")

    if backup_path.exists():
        return "skipped", "backup already exists"

    # --- Backup ---
    shutil.copy2(path, backup_path)

    try:
        deleted_ole, deleted_shapes = remove_embedded_objects(path)

        still_has = has_embeddings(path)

        return "sanitized", (
            f"sanitized "
            f"(deleted_ole={deleted_ole}, "
            f"deleted_shapes={deleted_shapes}, "
            f"still_has_embeddings={still_has})"
        )

    except Exception as e:
        # Rollback
        restored_backup = False
        try:
            if path.exists():
                path.unlink()
            shutil.copy2(backup_path, path)
            restored_backup = True
        except Exception:
            pass
        return "failed", f"FAILED (restored_backup={restored_backup}) error={e}"


def process_folder(root: Path, dry_run: bool, sleep: float):
    files = list(root.rglob("*.xlsx"))

    sanitized: List[Tuple[Path, str]] = []
    failed: List[Tuple[Path, str]] = []
    dry_run_hits: List[Tuple[Path, str]] = []
    skipped = 0

    print(f"Scanning {len(files)} xlsx files under {root}")

    for file in files:
        status, msg = sanitize_file(file, dry_run)

        if status == "sanitized":
            sanitized.append((file, msg))
            print(f"[SANITIZE] {file}\n  -> {msg}")
        elif status == "dry-run":
            dry_run_hits.append((file, msg))
            print(f"[DRY-RUN] {file}\n  -> {msg}")
        elif status == "failed":
            failed.append((file, msg))
            print(f"[FAILED] {file}\n  -> {msg}")
        else:
            skipped += 1

        if sleep:
            time.sleep(sleep)

    print("\n==============================")
    print("SUMMARY")
    print(f"sanitized: {len(sanitized)}")
    print(f"dry-run: {len(dry_run_hits)}")
    print(f"failed: {len(failed)}")
    print(f"skipped: {skipped}")

    if sanitized:
        print("\nSanitized files:")
        for p, _ in sanitized:
            print(f"- {p}")

    if failed:
        print("\nFailed files:")
        for p, _ in failed:
            print(f"- {p}")


# ============================================================
# CLI
# ============================================================


def main():
    parser = argparse.ArgumentParser(
        description="Sanitize xlsx files by removing embedded OLE objects (xl/embeddings)."
    )
    parser.add_argument("root", help="Root directory to scan")
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--sleep", type=float, default=0.0)

    args = parser.parse_args()

    if sys.platform != "win32":
        print("This script requires Windows (Excel COM).")
        sys.exit(1)

    if win32com is None:
        print("pywin32 not installed. Run: pip install pywin32")
        sys.exit(1)

    root = Path(args.root).resolve()

    if not root.is_dir():
        print("Invalid directory.")
        sys.exit(1)

    process_folder(root, args.dry_run, args.sleep)


if __name__ == "__main__":
    main()

参考