0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonでxlsxの再計算漏れを検出する

0
Posted at

数式が入っていればExcelは正しい。そう見えがちだけど、納品直前に見るべきなのは数式の有無より、計算済みの値が一緒に入っているかだと思っている。

8月10日に公開されたOpenAIのModel MLの事例でも、調査・分析の結果を編集可能で追跡できるExcelに落とす流れが扱われていた。編集できることは助かる。ただ、.xlsxを成果物として渡すなら、数式だけ残して「開いた人のExcelが計算してくれる」状態はちょっと危ない。プレビュー、PDF変換、別の処理系は、保存済みの値を読むことがある。

今回は外部ライブラリを使わず、.xlsxの中身を読んで二つの兆候をCIで落とすスクリプトを書いた。

  • ブックが手動計算モードになっている
  • 数式セルに計算結果のキャッシュがない

.xlsxの数式と表示値は別に入っている

さっき検証用のxlsxをZIPとして開いて、ここを改めて確認した。B3に残るのは次のようなXMLだ。

<c r="B3"><f>B2*2</f></c>

<f> が数式で、計算済みの値は <v> に入る。値があればこうなる。

<c r="B2"><f>SUM(A1:A2)</f><v>3</v></c>

数式と表示値は、同じ場所に保存されているわけではない。

さらに xl/workbook.xml の <calcPr calcMode="manual"/> は、手動計算モードを表す。手動計算そのものを禁止する意図はない。重いモデルの試算ではむしろ使う。ただし、これと空の <v> が同居したファイルを、検査なしで配る理由はあまりない。

Pythonで再計算漏れを検出する

以下を check_xlsx_recalc.py として保存する。.xlsxはZIPなので、zipfileとxml.etree.ElementTreeだけで読める。シート名とセル番地まで出すようにした。

import argparse
import json
import posixpath
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path

MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
REL_DOC = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
REL_PKG = "http://schemas.openxmlformats.org/package/2006/relationships"
NS = {"m": MAIN, "r": REL_DOC}

def scan_xlsx(path: Path) -> dict:
    issues = []
    with zipfile.ZipFile(path) as zf:
        workbook = ET.fromstring(zf.read("xl/workbook.xml"))
        calc = workbook.find("m:calcPr", NS)
        calc_mode = calc.get("calcMode", "auto") if calc is not None else "auto"
        if calc_mode == "manual":
            issues.append({"kind": "manual_calc_mode"})

        rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
        targets = {
            rel.get("Id"): posixpath.normpath(
                posixpath.join("xl", rel.get("Target"))
            )
            for rel in rels.findall(f"{{{REL_PKG}}}Relationship")
        }

        for sheet in workbook.findall("m:sheets/m:sheet", NS):
            target = targets[sheet.get(f"{{{REL_DOC}}}id")]
            root = ET.fromstring(zf.read(target))
            for cell in root.findall(".//m:c", NS):
                if cell.find("m:f", NS) is None:
                    continue
                cached = cell.find("m:v", NS)
                if cached is None or cached.text in (None, ""):
                    issues.append({
                        "kind": "formula_without_cached_value",
                        "sheet": sheet.get("name"),
                        "cell": cell.get("r"),
                    })
    return {"file": str(path), "issues": issues}

def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("xlsx", type=Path)
    args = parser.parse_args()
    report = scan_xlsx(args.xlsx)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 1 if report["issues"] else 0

if __name__ == "__main__":
    raise SystemExit(main())

実行はこれだけ。

python check_xlsx_recalc.py deliverable.xlsx

検証用に、B2だけにキャッシュ値を持たせ、B3は数式だけにしたxlsxで実行した。出力は次のとおりだった。終了コードも 1 になるので、GitHub Actionsなどではそのまま失敗扱いにできる。

{
  "file": "/tmp/forecast.xlsx",
  "issues": [
    {"kind": "manual_calc_mode"},
    {
      "kind": "formula_without_cached_value",
      "sheet": "試算",
      "cell": "B3"
    }
  ]
}

これは正しさの証明ではない

この検査で分かるのは「再計算されていないかもしれない」というファイル構造上の兆候までだ。<v> があっても、古い入力値で計算したキャッシュかは区別できない。外部リンク、Power Query、マクロ、動的配列まで含めるなら、生成に使ったアプリで開き直して計算し、値のスナップショットを別途比較する工程が要る。

逆に、空文字を返す数式では空の <v> が仕様どおりのこともある。このスクリプトを即座にブロックにせず、最初は警告としてCIに入れ、対象ブックの例外を集める方が現実的だった。

おわりに

AIがExcelを作る場面で、数式が入っていることは途中経過にすぎない。渡すファイルの利用者が見るのは計算式より表示値だ。

だから成果物チェックでは、数式のレビューに加えて、再計算の状態を検査する。ZIPの中を数十行で見れば、手動計算とキャッシュ欠落は拾える。生成側のモデルを替える前に、この検査を1本置く方が、壊れた試算表を渡す確率は確実に下がる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?