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

- Name
- Daisuke Kobayashi
- https://twitter.com
最近,会社で 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 側では InvalidRequest や UnsupportedContent として見えており,最終的なメッセージは次のようなものでした.
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 では,次の流れで処理しています.
.xlsxを ZIP として開いてxl/embeddings/があるか確認する.- 該当するファイルだけ Excel COM で開く.
- 各シートの
OLEObjects()とOLEFormatを持つ Shape を削除する. - 保存して,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()