はじめに
- openpyxlで既存のxlsxを開いて保存し直すと、openpyxlが扱えない図形・フォームコントロールなどは消えます。グラフも作り直されるので、見た目が変わるか、作りによってはグラフごと消えます
- xlsxの正体は「XMLファイルをZIPで固めたもの」なので、ZIPを開いて目的のXMLだけを文字列として書き換え、他のパーツはバイト列のまま戻せば、触っていないパーツは1バイトも変えずに残せます
- Claude Codeにはこの方針を明示して頼んでいます。この記事では、グラフ・図形・ボタン入りのExcelを実際に2通りで編集し、Excelで開いた画面で違いを比べます
読了時間は10分ほどです。
対象読者: Claude CodeなどのAIエージェントに、既存のExcelファイルを編集させている人
私は業務で、Excelのレポートテンプレートに集計結果を流し込む作業をClaude Codeにやらせています。最初は素直にopenpyxlで書き込ませていたのですが、保存したテンプレートを開いたら、グラフも図形もボタンもごっそり消えていました。それ以来、ExcelはXMLのまま編集させています。なぜそれで壊れないのかを、自分でも説明できるように整理しました。
この記事で扱うのは「既存のxlsxを壊さずに一部だけ書き換える」ことです。ゼロから新しいExcelを作る方法は扱いません。
参考文献
xlsxの中身がZIPとXMLであること、それがLLMでExcelを編集できる理由になっていることは、次の記事がとても分かりやすくまとめています。この記事の「そもそも」の部分はほぼこの記事に乗っかっています。
openpyxlの公式チュートリアルです。既存ファイルを開いて保存したときに失われるものについての注意書きがあります。
SpreadsheetML(xlsxの中身のXML)のパーツ構成とリレーションシップの説明です。
Office Open XMLの規格本体(ECMA-376)です。
Pythonの zipfile モジュールのドキュメントです。
検証環境
| 項目 | バージョン |
|---|---|
| OS | macOS(Apple Silicon) |
| Microsoft Excel | Microsoft 365 for Mac(16.113.2) |
| Python | 3.14 |
| openpyxl | 3.1.5 |
| XlsxWriter | 3.2.9(サンプルの作成にだけ使用) |
業務のExcelをopenpyxlで保存したら、図形とPower Queryが消えた
冒頭の失敗をもう少しだけ書きます。
対象は、社内で使っているレポートのテンプレートでした。グラフ、説明用の図形、ボタンなどのフォームコントロール、Power Queryのクエリが入った、よくある「作り込まれたExcel」です。Claude Codeに「このシートにデータを書き込んで」と頼むと、load_workbook() で開いて、セルに値を入れて、save() するスクリプトが出てきます。コードとしては何も間違っていません。
ところが保存されたファイルを開くと、グラフも、図形も、ボタンも、Power Queryも消えていました。スクリプトはエラーなく終わっているので、気づけるのはExcelで開いたときだけです。後で出てくるサンプルでも、openpyxlは警告を全部表示する設定(python -W always)にしてもエラー・警告とも出しませんでした。
これはopenpyxlのバグではなく仕様です。そして、Claude Codeに入れているAnthropic公式のxlsxスキルの説明を読むと、既存ファイルの編集にもopenpyxlを使う方針が書かれていました(執筆時点)。つまり、何も言わずに頼むと、この壊れ方を普通に踏みます。
そもそもxlsxの正体はZIPとXML
ここは参考文献に挙げた記事の要点を借ります。
xlsxは「ZIPで圧縮されたXMLファイルの集まり」で、OPC(Open Packaging Conventions)という共通の仕組みに沿っています。docxやpptxも同じ仕組みです。中身が人が読めるXMLテキストなので、ZIPを解凍してXMLを編集し、ZIPに戻すだけで編集できる。だからClaudeのような言語モデルでもExcelを編集できる、という話でした。旧形式の .xls のようなバイナリだと、こうはいきません。
実際に、今回のサンプル(後で出てくる、Excelで保存したxlsx)の中身を一覧すると、こうなっています。
unzip -Z1 sample.xlsx
[Content_Types].xml
_rels/.rels
docProps/app.xml
docProps/core.xml
xl/_rels/workbook.xml.rels
xl/calcChain.xml
xl/charts/chart1.xml
xl/ctrlProps/ctrlProp1.xml
xl/drawings/_rels/drawing1.xml.rels
xl/drawings/drawing1.xml
xl/drawings/vmlDrawing1.vml
xl/sharedStrings.xml
xl/styles.xml
xl/theme/theme1.xml
xl/workbook.xml
xl/worksheets/_rels/sheet1.xml.rels
xl/worksheets/sheet1.xml
17個のファイルが入っていて、この記事ではこれを「パーツ」と呼びます。セルの値は sheet1.xml、グラフは chart1.xml、テキストボックスの配置は drawing1.xml、ボタンは vmlDrawing1.vml と ctrlProp1.xml というように、機能ごとに別のパーツに分かれています。
セル1つは、sheet1.xml の中でこんな形をしています。
<c r="B15" s="4"><v>1300</v></c>
r がセル番地、s が書式の番号、<v> が値です。この 1300 を 1500 に書き換えれば、それだけで12月の売上が変わります。
openpyxlで保存すると何が起きるのか
同じ編集を2通りでやって比べます。
サンプルとして、月別売上の表、合計の数式、棒と折れ線の複合グラフ、テキストボックス2つ、フォームコントロールのボタン1つを置いたExcelを用意しました。XlsxWriterで作ったあと、一度Excelで開いて保存し直しています。Excelが保存したファイルでないと、calcChain.xml などが入らず、現実のファイルと構成が変わってしまうからです。
これをopenpyxlで開いて、12月の売上(B15)を1,300から1,500に変えて保存します。
from openpyxl import load_workbook
wb = load_workbook("sample.xlsx")
ws = wb["売上"]
ws["B15"] = 1500
wb.save("edited_openpyxl.xlsx")
保存したファイルをExcelで開いた画面がこちらです。
B15は1,500になり、合計も12,280に計算し直されています。書き換えたかったこと自体はできています。
ただ、ボタンとテキストボックス2つが跡形もありません。グラフは残っていますが、タイトルの文字が小さくなり、枠の角が丸くなっています。元のグラフとは別物です。
パーツの一覧を比べると、何が起きたかがはっきりします。
| パーツ | 元のファイル | openpyxlで保存後 |
|---|---|---|
xl/drawings/vmlDrawing1.vml(ボタンの見た目) |
あり | なし |
xl/ctrlProps/ctrlProp1.xml(ボタンの設定) |
あり | なし |
xl/calcChain.xml(数式の計算順) |
あり | なし |
xl/sharedStrings.xml(文字列の一覧) |
あり | なし(文字列はセルの中に直接書く形に変わる) |
xl/drawings/drawing1.xml |
グラフ枠+テキストボックス2つ | グラフ枠だけ |
| パーツ数 | 17 | 13 |
なぜopenpyxlだと消えて、XML直接編集だと残るのか
理由はシンプルで、openpyxlは「開いたファイルを編集している」のではなく、「読み込んだ内容から、新しいファイルをゼロから書き出している」からです。
load_workbook() は、ZIPの中のXMLを読んで、openpyxlのPythonオブジェクト(ワークブック、シート、セル、書式、グラフなど)に変換します。save() はそのオブジェクトを元に、XMLを1から書き直してZIPに詰めます。
このとき、openpyxlに対応するクラスがない部品は、読み込んだ時点で行き場がありません。公式チュートリアルにも、openpyxlはExcelファイルのすべての要素を読み込むわけではないので、既存ファイルを開いて保存すると図形が失われる、という注意書きがあります。今回のボタンやテキストボックスがまさにこれです。
グラフが残ったのは、openpyxlがグラフを読み込むクラスを持っているからです。ただし「元のXMLを残した」わけではなく、「読み込んだグラフのオブジェクトから、グラフのXMLを書き直した」ので、openpyxlのモデルに載らなかった細かい設定(タイトルの文字サイズなど)が落ちて、見た目が変わりました。文字列の持ち方や書式の番号が変わったのも同じ理由で、全部が書き直されています。
なお、冒頭の業務テンプレートではグラフ自体も消えました。openpyxlが読み込めない種類か構造のグラフだったのだと思いますが、どの設定が原因だったのかまでは追っていません。「グラフはopenpyxlでも残る」とは言い切れない、というのが実感です。
一方、XML直接編集では、書き換えたいパーツだけを文字列として置換し、残りのパーツは「ZIPから読んだバイト列をそのまま」書き戻します。ボタンのXMLもグラフのXMLも、そもそもプログラムが中身を解釈していません。解釈していないものは壊しようがない、というのがこの方法の要です。
XML直接編集で同じ変更をしてみる
実際に、同じ「B15を1,500にする」をXML直接編集でやります。
import re
import zipfile
SRC = "sample.xlsx"
DST = "edited_xml.xlsx"
SHEET = "xl/worksheets/sheet1.xml"
def set_number(xml: str, ref: str, value: int) -> str:
# <c r="B15" s="4"><v>1300</v></c> の <v> だけを差し替える(書式の s 属性は残す)
pattern = re.compile(rf'(<c r="{ref}"[^>]*>)<v>[^<]*</v>(</c>)')
new_xml, n = pattern.subn(lambda m: f"{m.group(1)}<v>{value}</v>{m.group(2)}", xml)
if n != 1:
raise ValueError(f"{ref} が見つからない({n}件)")
return new_xml
def force_recalc(xml: str) -> str:
# 合計の数式が持っている計算結果が古いままなので、開いたときに再計算させる
if "<calcPr" in xml:
# 既存の fullCalcOnLoad("0" の場合もある)を消してから付け直す
xml = re.sub(r'(<calcPr\b[^>]*?)\s+fullCalcOnLoad="[^"]*"', r"\1", xml, count=1)
return re.sub(r"<calcPr\b", '<calcPr fullCalcOnLoad="1"', xml, count=1)
# calcPr は要素の並び順が決まっているので、無いときは足さずに止める
raise ValueError("workbook.xml に calcPr が無い")
with zipfile.ZipFile(SRC) as zin, zipfile.ZipFile(DST, "w") as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == SHEET:
data = set_number(data.decode("utf-8"), "B15", 1500).encode("utf-8")
elif item.filename == "xl/workbook.xml":
data = force_recalc(data.decode("utf-8")).encode("utf-8")
# それ以外のパーツは読んだバイト列をそのまま書き戻す
zout.writestr(item, data, compress_type=zipfile.ZIP_DEFLATED)
やっていることは3つです。
- ZIPの中のパーツを、元の順番のまま1つずつ読む
-
sheet1.xmlのB15の値と、workbook.xmlの再計算の設定だけを文字列で書き換える - それ以外は読んだバイト列をそのまま新しいZIPに書く
セルを探す正規表現は、今回のファイルのXMLの形(<c r="B15" s="4"><v>…</v></c>)に合わせたものです。属性の順番や、数式セルの <f> の有無はファイルによって違うので、別のファイルにそのまま使うと見つからないことがあります。その場合に黙って進まないよう、1件ちょうど見つからなければ例外で止めています。XMLパーサーで処理する手もありますが、Python標準の ElementTree で書き戻すと名前空間の接頭辞が書き換わってしまうので、書き戻しまでやるなら接頭辞を保てるライブラリを選んでください。
Excelで開くと、こうなります。
ボタンもテキストボックスも、グラフのタイトルや枠も元のままです。変わったのはB15と、それに連動する合計とグラフの12月の棒だけです。
解凍して元のファイルと比べると、違うのは書き換えた2つのパーツだけでした。
diff -rq original_unzipped/ edited_unzipped/
Files original_unzipped/xl/workbook.xml and edited_unzipped/xl/workbook.xml differ
Files original_unzipped/xl/worksheets/sheet1.xml and edited_unzipped/xl/worksheets/sheet1.xml differ
合計(B16)のセルには、数式 SUM(B4:B15) と一緒に「前回の計算結果」も入っています。セルだけ書き換えると、この計算結果は古いまま残ります。workbook.xml の <calcPr> に fullCalcOnLoad="1" を付けておくと、Excelが開いたときに全部を計算し直します。グラフの chart1.xml の中にも値のキャッシュがありますが、今回はExcelで開いた時点でセルの値に合わせて描き直されました。Excel以外のビューアーで開く場合は、キャッシュの古い値が表示される可能性があります(手元では確認していません)。
xlsxの中はリレーションシップでつながっている
セルの値を書き換えるだけなら上のコードで足ります。ただ、「シートを1枚足す」「パーツを1つ消す」まで踏み込むと、パーツ同士のつながりを意識する必要が出てきます。
xlsxの中のパーツは、「参照元のXMLにあるID」と「.rels ファイルでのIDと実際のパスの対応」の2段で互いを参照しています。たとえば sheet1.xml の中には <drawing r:id="rId1"/> とIDだけが書かれていて、rId1 が ../drawings/drawing1.xml を指すことは xl/worksheets/_rels/sheet1.xml.rels に書かれています。さらに [Content_Types].xml は、拡張子ごとの既定の種類(Default)と、パーツごとの個別の種類(Override)で、各パーツが何のファイルかを定めています。
なので、パーツを足したり消したりするときは、参照元のXML・.rels・[Content_Types].xml のうち、そのパーツに関係するものをすべて揃えて直す必要があります。たとえばシートを1枚足すなら、シートのXML本体、workbook.xml の <sheet>、workbook.xml.rels の対応、[Content_Types].xml のOverrideの4か所です。どこか1つでも食い違うと、Excelで開いたときに「修復しますか?」と聞かれます。参考文献の記事で「手で書き換えると整合性が崩れて壊れることがある」と書かれているのは、主にこの部分の話だと私は理解しています。
逆に言えば、セルの値の書き換えのように既存のパーツの中身だけを変える作業なら、.rels も [Content_Types].xml も触らずに済みます。XML直接編集が一番安全なのはこの範囲です。
Claudeにやらせるときの頼み方
Claudeは、何も言わなければopenpyxlで開いて保存するコードを書きます。そこで、作業するリポジトリの CLAUDE.md に次のような原則を書いておき、毎回それに従わせています。
## 既存のExcelを編集するときの原則
- openpyxl の load_workbook → save は禁止。読み取り専用にのみ使う
- xlsx は zip として開き、書き換えるパーツ(xl/worksheets/sheetN.xml など)だけを文字列で編集する
- それ以外のパーツは読んだバイト列のまま書き戻す
- 文字列のセルは t="inlineStr"(中身は <is><t>…</t></is>)で書き、sharedStrings.xml には触らない
- 数値や数式を変えたら workbook.xml の calcPr に fullCalcOnLoad="1" を付ける
- 作業後は、元ファイルとパーツ数が一致すること、全XMLがパースできることを確認する
頼むたびに「XMLで編集して」と書いても効きますが、1回書き忘れるだけでテンプレートが壊れます。人の記憶に頼らないよう、CLAUDE.md に置いておくのがおすすめです。
壊さないためのルール
実際に運用して、これは守ったほうがいいと感じたものをまとめます。
| ルール | 理由 |
|---|---|
文字列は t="inlineStr" でセルの中に直接書く |
sharedStrings.xml の一覧とインデックス番号を管理しなくて済む。形は <c r="A1" t="inlineStr"><is><t>文字列</t></is></c> で、<v> は使わない。先頭や末尾に空白があるなら <t xml:space="preserve"> にする |
値に & や < が入るならエスケープする |
XMLとして読めなくなり、ファイルが開けなくなる。Pythonなら xml.sax.saxutils.escape を使う。ただしXMLで使えない制御文字(タブ・改行以外の \x00〜\x1f など)は escape では消えないので、別に取り除く |
数値や数式を変えたら fullCalcOnLoad="1" を付ける |
数式が持つ計算結果のキャッシュが古いまま残るため |
数式を足したり消したりしたら calcChain.xml を消す |
数式の計算順の記録と実際の数式が食い違うと「修復」になることがある(私は数式を足したときに踏んだ)。消すときは .rels と [Content_Types].xml からも消す。calcChainは無くても計算でき、Excelが必要に応じて内部で組み立て直す |
re.sub の置換文字列は関数(lambda)で渡す |
値に \ が含まれると、置換文字列の中でグループ参照として解釈されて壊れる |
| 編集しないパーツはデコードもしない | 文字コードや改行を変えてしまう余地をなくす。バイト列のまま書き戻す |
壊れていないかの確かめ方
私は毎回、次の3つを見ています。
-
パーツ数が元と一致するか。
unzip -Z1の行数を比べるだけです。意図して足したり消したりしたパーツ以外に増減があれば、何かを落としています -
XMLとして正しく読めて、参照先が全部あるか。ZIPの中の
.xmlと.relsを全部パースし、.relsが指しているパーツが実在するかを確かめます - Excelで実際に開くか。「修復しますか?」が出たら失敗です。最後はこれに勝るものはありません
1と2はClaudeに自動でやらせて、3だけ人が見る分担にしています。
import posixpath
import sys
import zipfile
import xml.etree.ElementTree as ET
REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
before, after = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(before) as a, zipfile.ZipFile(after) as b:
names_a, names_b = set(a.namelist()), set(b.namelist())
print("消えたパーツ:", sorted(names_a - names_b))
print("増えたパーツ:", sorted(names_b - names_a))
for name in b.namelist():
if not name.endswith((".xml", ".rels")):
continue
root = ET.fromstring(b.read(name)) # XMLとして壊れていればここで例外になる
if name.endswith(".rels"):
# 例: xl/worksheets/_rels/sheet1.xml.rels の参照先は xl/worksheets/ 基準
base = posixpath.dirname(posixpath.dirname(name))
for rel in root.iter(f"{REL_NS}Relationship"):
if rel.get("TargetMode") == "External":
continue
target = rel.get("Target")
path = target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join(base, target))
if path not in names_b:
print("参照先が無い:", name, "->", target)
print("チェック完了")
今回のサンプルで試すと、XML直接編集版は「消えたパーツ」「増えたパーツ」ともに空、openpyxl版は「消えたパーツ」に4つ並びます。わざとグラフのパーツを抜いたファイルでは、「参照先が無い」が出ることも確かめました。
.vml はチェックの対象から外しています。Excelが書き出すVMLは、XMLとしての書式が厳密でないことがあり、正常なファイルでも例外になり得るからです。また、このチェックで分かるのは「XMLとして読めるか」と「参照先があるか」までです。Excelとして矛盾がないかは、3で確かめるしかありません。
この方法が向かない場面
XML直接編集は万能ではありません。私はこう使い分けています。
| やりたいこと | XML直接編集を使わない理由と、代わりの手段 |
|---|---|
| ゼロから新しいExcelを作る | 壊すものがないので、XlsxWriterなどで作るほうが速くて安全 |
| 読むだけ | 保存しなければ何も壊れないので、openpyxlやpandasで読めばよい |
| 行や列を挿入してずらす | 数式・結合セル・図形の位置・テーブル範囲など、ずれた分を直す箇所が多すぎる。Excel本体(VBAやCOM)に任せるほうが確実 |
| 図形もグラフもないシンプルなファイル | openpyxlで保存しても失うものがない |
| デジタル署名付きのファイル | 署名の対象になっているパーツを1バイトでも変えると、署名は無効になる。どの方法で編集しても同じ |
逆に、この方法が一番効くのは「作り込まれたテンプレートに、決まった場所へ値を流し込む」作業です。業務のレポート作成はほとんどこれなので、私の場合は出番がかなり多いです。
まとめ
openpyxlで図形が消えるのは、ファイルを編集しているのではなく、読み込んだ内容から作り直しているからでした。xlsxがZIPとXMLでできている以上、触りたいパーツだけを書き換えて、残りはバイト列のまま戻せば、プログラムが知らない部品まで丸ごと残せます。
Claude Codeに任せるときは、この「作り直さない」という方針を CLAUDE.md に書いておくだけで、壊れる事故はほぼなくなりました。
次は同じやり方をpptxでも試すつもりです。PowerPointのグラフは、中に埋め込まれたExcelとグラフのXMLのキャッシュの両方に値を持っているので、xlsxより一段ややこしそうです。みなさんはAIにExcelを触らせるとき、何か決まりを作っていますか? よければ教えてください。




