AIで作ったWordの請求書テンプレートに、消したはずの値引き額が残る

Pandoc で docx をテキストにすると、単価の行は「単価は 4,980 円です」と出ました。ところが同じファイルに--track-changes=allを付けると、「単価は 9,800 円(A 社向け特別価格)4,980 円です」に変わります。画面に見えない過去の値引き前の金額が、ファイルの中に残っていたということです。
2026 年 10 月 7 日、AI で下書きした請求書テンプレートをそのまま販売する場面を想定して、docx の最小サンプルを 8 つ作りました。先に断っておくと、8 つとも私が手で組み立てた見本です。AI の出力に、こうした痕跡がどのくらいの頻度で残るのかは測っていません。測ったのは、検査スクリプトが見本を止められるかどうかだけです。
最初は作成者欄だけを見る検査を書きました。結果は、不備のある 6 件のうち 1 件しか止まりませんでした。
前回完了時と今回完了後
10 月 6 日の回では、販売用 SVG の script、イベント属性、外部参照を XML として読む検査を作りました。今回は配るファイルを Word の docx に変え、本文には見えない変更履歴やコメントを調べます。
| 時点 | 検査できること | 見えていないこと |
|---|---|---|
| 前回完了時(10/6 SVG回まで) | SVGの<script>、イベント属性、外部参照、CSSのurl()と@import | docxの中身。本文に見えない履歴やコメント |
| 今回完了後 | 新規: docxの作成者欄、変更履歴、コメント、非表示テキスト、外部リンクの検査と除去 | Wordで開いたときの実表示。ヘッダー、フッター、埋め込み画像、docProps/app.xmlなど今回の見本に入れなかった場所 |
飛ばせる章: docx が zip と XML でできていると知っている人は「なぜ見た目の確認では足りないのか」を飛ばして「8つの見本を用意する」へ進めます。結果だけ見たい人は「XMLを読む検査に作り直す」の出力へ。除去だけが目的なら「取り除いて本文を比べる」へ。
なぜ見た目の確認では足りないのか
docx は、XML ファイルをいくつか zip でまとめた形式です。本文はword/document.xml、コメントはword/comments.xml、作成者はdocProps/core.xmlに入っています。Word の画面は、このうち「今見せる設定になっているもの」だけを描きます。
Microsoft の資料では、w:delは「変更履歴として記録された、削除済みの内容」を指す要素です。承認しないまま保存すると、削除した文字列もファイルに残ります。コメントは別ファイルに入ります。w:vanishを付けた文字は、画面にも、印刷物にも現れません。ただ、プログラムでファイルを読めば、その文字は取り出せます。
AI に文面を作らせて、人間が Word で何度も直す。この流れだと、履歴が積み重なりやすくなります。値引き前の金額や、前の取引先の口座へのメモが商品に残れば、買い手に見えてしまいます。
[AIが下書き] → [人がWordで修正] → [見た目を確認]
│ 見えない層は分からない
▼
[検査: 作成者 / 履歴 / コメント / 非表示 / 外部リンク]
│
┌─────────┴─────────┐
PASS FAIL
│ │
▼ ▼
[販売候補] [除去 → 再検査 → 本文を比較]
用語
| 用語 | この回での意味 |
|---|---|
| docx | Word の文書形式です。XML を zip で束ねたファイルです |
| zip | 複数のファイルを 1 つにまとめる形式です。Python 標準のzipfileで読めます |
| XML | タグで構造を表す書式です。xml.etree.ElementTreeで解析します |
| 名前空間 | XML のタグの語彙を区別する印です。Word のタグはw:の接頭辞で書かれます |
| core properties | docProps/core.xmlにある作成者、最終更新者などの属性です |
| 変更履歴 | 追加(w:ins)と削除(w:del)を、承認前の状態で記録したものです |
| コメント | 本文の横に付けるメモです。word/comments.xmlに入ります |
w:vanish | その文字を非表示にする指定です。w:val="0"なら非表示になりません |
| スタイル | 文字の見た目をまとめて指定する仕組みです。スタイルの中にw:vanishを書けます |
| 外部リンク | TargetMode="External"のリレーションです。ファイルパスや URL を指します |
| Pandoc | 文書形式を変換するコマンドです。ここでは docx をテキストにして見比べます |
| 偽陰性 | 不備があるのに PASS と判定することです |
| 偽陽性 | 問題がないのに FAIL と判定することです |
| 正規表現 | 文字列のパターンを書く記法です。Python のreで使います |
8つの見本を用意する
実験用のフォルダを作り、環境を確認しました。
mkdir hibanas-docx-lab
cd hibanas-docx-lab
python3 --version
pandoc --version | head -1
Python 3.13.5
pandoc 3.1.11.1
見本は 8 つです。見本の内訳は、問題のない請求書 1 つ、作成者欄だけに不備を入れたもの 1 つ、本文側に不備を 1 種類ずつ入れたもの 5 つです。最後の 1 つは、w:vanishを持ちながらw:val="0"で表示される、問題のないものです。後で偽陽性を見るために入れました。make_docx.pyの全文です。
import zipfile
NS = ('xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"')
REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
COMMENT_CT = ('<Override PartName="/word/comments.xml" ContentType="application/'
'vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>')
CONTENT_TYPES = XML + (
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
+ COMMENT_CT +
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
'</Types>')
ROOT_RELS = XML + (
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
f'<Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/>'
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
'</Relationships>')
def core(creator="hibanas", last="hibanas"):
return XML + (
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
f'<dc:title>請求書テンプレート</dc:title><dc:creator>{creator}</dc:creator>'
f'<cp:lastModifiedBy>{last}</cp:lastModifiedBy>'
'<dcterms:created xsi:type="dcterms:W3CDTF">2026-10-06T08:00:00Z</dcterms:created>'
'</cp:coreProperties>')
def run(text):
return f'<w:r><w:t xml:space="preserve">{text}</w:t></w:r>'
def para(*parts):
return "<w:p>" + "".join(parts) + "</w:p>"
BASE = [para(run("請求書")), para(run("お支払い期限は発行日から30日です。"))]
WHEN = 'w:author="Claude" w:date="2026-10-06T08:00:00Z"'
samples = {
"a-clean": {},
"b-core-owner": {"core": core("山田太郎", "daisuke-pc")},
"c-tracked-delete": {"extra": [para(
run("単価は"),
f'<w:del w:id="1" {WHEN}><w:r><w:delText xml:space="preserve">9,800円(A社向け特別価格)</w:delText></w:r></w:del>',
run("4,980円です。"))]},
"d-comment": {
"extra": [para('<w:commentRangeStart w:id="0"/>', run("振込先は別紙のとおりです。"),
'<w:commentRangeEnd w:id="0"/>', '<w:r><w:commentReference w:id="0"/></w:r>')],
"comments": XML + f'<w:comments {NS}><w:comment w:id="0" {WHEN}><w:p><w:r><w:t>TODO: 取引先ごとに口座を差し替える。前回の案件の口座番号のまま</w:t></w:r></w:p></w:comment></w:comments>'},
"e-hidden-run": {"extra": [para(
run("備考"),
'<w:r><w:rPr><w:vanish/></w:rPr><w:t xml:space="preserve">プロンプト: 丁寧な文面で請求書を作って。社名はA社。</w:t></w:r>')]},
"f-hidden-style": {
"styles": '<w:style w:type="character" w:styleId="Memo"><w:name w:val="Memo"/><w:rPr><w:vanish/></w:rPr></w:style>',
"extra": [para(run("担当者"),
'<w:r><w:rPr><w:rStyle w:val="Memo"/></w:rPr><w:t xml:space="preserve">内部メモ: 値引き上限は15%</w:t></w:r>')]},
"g-external-link": {
"extra": [para('<w:hyperlink r:id="rId9">' + run("支払いページ") + "</w:hyperlink>")],
"rels": f'<Relationship Id="rId9" Type="{REL}/hyperlink" Target="file:///C:/Users/daisuke-pc/Desktop/invoice-old.xlsx" TargetMode="External"/>'},
"h-vanish-off": {"extra": [para(
'<w:r><w:rPr><w:vanish w:val="0"/></w:rPr><w:t xml:space="preserve">この行は表示されます。</w:t></w:r>')]},
}
for name, s in samples.items():
has_comments = "comments" in s
types = CONTENT_TYPES if has_comments else CONTENT_TYPES.replace(COMMENT_CT, "")
rels = XML + ('<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
f'<Relationship Id="rId1" Type="{REL}/styles" Target="styles.xml"/>')
if has_comments:
rels += f'<Relationship Id="rId2" Type="{REL}/comments" Target="comments.xml"/>'
rels += s.get("rels", "") + "</Relationships>"
body = "".join(BASE + s.get("extra", []))
document = XML + f"<w:document {NS}><w:body>{body}<w:sectPr/></w:body></w:document>"
styles = XML + f'<w:styles {NS}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{s.get("styles", "")}</w:styles>'
with zipfile.ZipFile(f"{name}.docx", "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", types)
z.writestr("_rels/.rels", ROOT_RELS)
z.writestr("docProps/core.xml", s.get("core", core()))
z.writestr("word/document.xml", document)
z.writestr("word/styles.xml", styles)
z.writestr("word/_rels/document.xml.rels", rels)
if has_comments:
z.writestr("word/comments.xml", s["comments"])
print("wrote", f"{name}.docx")
python3 make_docx.py
wrote a-clean.docx
wrote b-core-owner.docx
wrote c-tracked-delete.docx
wrote d-comment.docx
wrote e-hidden-run.docx
wrote f-hidden-style.docx
wrote g-external-link.docx
wrote h-vanish-off.docx
| ファイル | 入れた不備 |
|---|---|
| a-clean | なし |
| b-core-owner | 作成者と最終更新者に人名とPC名 |
| c-tracked-delete | 削除済みの値引き前の金額(変更履歴) |
| d-comment | 前の案件の口座を示すコメント |
| e-hidden-run | w:vanishで隠した制作時のプロンプト文 |
| f-hidden-style | スタイルMemoの中のw:vanishで隠した内部メモ |
| g-external-link | 手元PCのファイルパスを指す外部リンク |
| h-vanish-off | w:vanish w:val="0"(表示される。問題なし) |
作成者欄だけを見る検査を試す
最初は、docProps/core.xmlの作成者だけを見るfirst_check.pyを書きました。空か、hibanasならよしとします。
import re
import sys
import zipfile
bad = 0
for path in sys.argv[1:]:
with zipfile.ZipFile(path) as z:
core = z.read("docProps/core.xml").decode("utf-8")
m = re.search(r"<dc:creator>(.*?)</dc:creator>", core)
creator = m.group(1) if m else ""
ok = creator in ("", "hibanas")
print(f"{path}: {'PASS' if ok else 'FAIL'}")
bad += not ok
sys.exit(1 if bad else 0)
python3 first_check.py *.docx
echo "exit=$?"
a-clean.docx: PASS
b-core-owner.docx: FAIL
c-tracked-delete.docx: PASS
d-comment.docx: PASS
e-hidden-run.docx: PASS
f-hidden-style.docx: PASS
g-external-link.docx: PASS
h-vanish-off.docx: PASS
exit=1
不備のある 6 件のうち、止まったのは b だけです。残る 5 件(変更履歴、コメント、非表示の run、非表示スタイル、外部リンク)が PASS になりました。問題のない見本の 2 件は、PASS で合っています。作成者欄を直して安心するところまでは、前回の xlsx 回と同じ失敗です。
文字列を探す検査に広げる
次に、docx の XML に直接当てる正規表現の検査にしました。check_regex.pyです。作成者に加えて、変更履歴のタグ、コメントのファイル、w:vanish、外部リンクを探します。
import re
import sys
import zipfile
def check(path):
problems = []
with zipfile.ZipFile(path) as z:
names = z.namelist()
core = z.read("docProps/core.xml").decode("utf-8")
doc = z.read("word/document.xml").decode("utf-8")
rels = z.read("word/_rels/document.xml.rels").decode("utf-8")
for tag in ("dc:creator", "cp:lastModifiedBy"):
m = re.search(rf"<{tag}>(.*?)</{tag}>", core)
if m and m.group(1) not in ("", "hibanas"):
problems.append(f"{tag} = {m.group(1)}")
if "<w:del " in doc or "<w:ins " in doc:
problems.append("変更履歴 (w:del / w:ins)")
if "word/comments.xml" in names:
problems.append("コメント (word/comments.xml)")
if "<w:vanish" in doc:
problems.append("非表示テキスト (w:vanish)")
for target in re.findall(r'Target="([^"]+)"[^>]*TargetMode="External"', rels):
problems.append(f"外部リンク {target[:50]}")
return problems
bad = 0
for path in sys.argv[1:]:
problems = check(path)
print(f"{path}: {'FAIL' if problems else 'PASS'}")
for p in problems:
print(" -", p)
bad += bool(problems)
sys.exit(1 if bad else 0)
python3 check_regex.py *.docx
echo "exit=$?"
a-clean.docx: PASS
b-core-owner.docx: FAIL
- dc:creator = 山田太郎
- cp:lastModifiedBy = daisuke-pc
c-tracked-delete.docx: FAIL
- 変更履歴 (w:del / w:ins)
d-comment.docx: FAIL
- コメント (word/comments.xml)
e-hidden-run.docx: FAIL
- 非表示テキスト (w:vanish)
f-hidden-style.docx: PASS
g-external-link.docx: FAIL
- 外部リンク file:///C:/Users/daisuke-pc/Desktop/invoice-old.xl
h-vanish-off.docx: FAIL
- 非表示テキスト (w:vanish)
exit=1
6 件中 5 件を止められました。ただ、2 つの穴があります。
- f-hidden-style が PASS です。
w:vanishは document.xml ではなく styles.xml の中にあり、本文側はrStyleで名前を呼ぶだけだからです。偽陰性です。 - h-vanish-off が FAIL です。
<w:vanishという文字列があるだけで止めているため、表示される指定まで止めました。偽陽性です。
grep 的な検査の穴を、別の形で作っていました。
XMLを読む検査に作り直す
文字列ではなく、XML として読むことにしました。styles.xml からw:vanishが有効なスタイルの名前を集め、本文のrStyleがその名前を使っていれば非表示として扱います。w:vanishはw:valが0、false、offなら無効です。check_xml.pyの全文です。
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
def is_on(el):
return el.get(W + "val", "1").lower() not in ("0", "false", "off")
def check(path):
problems = []
with zipfile.ZipFile(path) as z:
names = z.namelist()
core = z.read("docProps/core.xml").decode("utf-8")
doc = ET.fromstring(z.read("word/document.xml"))
styles = ET.fromstring(z.read("word/styles.xml"))
rels = z.read("word/_rels/document.xml.rels").decode("utf-8")
for tag in ("dc:creator", "cp:lastModifiedBy"):
m = re.search(rf"<{tag}>(.*?)</{tag}>", core)
if m and m.group(1) not in ("", "hibanas"):
problems.append(f"{tag} = {m.group(1)}")
if doc.find(f".//{W}del") is not None or doc.find(f".//{W}ins") is not None:
problems.append("変更履歴 (w:del / w:ins)")
if "word/comments.xml" in names:
problems.append("コメント (word/comments.xml)")
hidden_styles = {
s.get(W + "styleId")
for s in styles.iter(W + "style")
if any(is_on(v) for v in s.iter(W + "vanish"))
}
for rpr in doc.iter(W + "rPr"):
if any(is_on(v) for v in rpr.findall(W + "vanish")):
problems.append("非表示テキスト (w:vanish)")
rs = rpr.find(W + "rStyle")
if rs is not None and rs.get(W + "val") in hidden_styles:
problems.append(f"非表示スタイル {rs.get(W + 'val')}")
for target in re.findall(r'Target="([^"]+)"[^>]*TargetMode="External"', rels):
problems.append(f"外部リンク {target[:50]}")
return problems
bad = 0
for path in sys.argv[1:]:
problems = check(path)
print(f"{path}: {'FAIL' if problems else 'PASS'}")
for p in problems:
print(" -", p)
bad += bool(problems)
sys.exit(1 if bad else 0)
python3 check_xml.py *.docx
echo "exit=$?"
a-clean.docx: PASS
b-core-owner.docx: FAIL
- dc:creator = 山田太郎
- cp:lastModifiedBy = daisuke-pc
c-tracked-delete.docx: FAIL
- 変更履歴 (w:del / w:ins)
d-comment.docx: FAIL
- コメント (word/comments.xml)
e-hidden-run.docx: FAIL
- 非表示テキスト (w:vanish)
f-hidden-style.docx: FAIL
- 非表示スタイル Memo
g-external-link.docx: FAIL
- 外部リンク file:///C:/Users/daisuke-pc/Desktop/invoice-old.xl
h-vanish-off.docx: PASS
exit=1
不備のある 6 件はすべて FAIL で、問題のない a と h だけが PASS になりました。
取り除いて本文を比べる
止めたあとは、AI や人に作り直してもらう手もあります。ただ、毎回頼むと時間がかかるので、痕跡を機械的に取り除くclean_docx.pyも用意しました。最初の版です。
import re
import sys
import zipfile
def clean(src, dst):
removed = 0
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == "word/comments.xml":
removed += 1
continue
text = data.decode("utf-8") if item.filename.endswith((".xml", ".rels")) else None
if item.filename == "docProps/core.xml":
text, n = re.subn(r"(<(?:dc:creator|cp:lastModifiedBy)>)(?!hibanas<).+?(</)", r"\1hibanas\2", text)
removed += n
elif item.filename == "word/document.xml":
text, n = re.subn(r"<w:del .*?</w:del>", "", text, flags=re.S)
removed += n
text, n = re.subn(
r"<w:commentRangeStart[^>]*/>|<w:commentRangeEnd[^>]*/>|<w:r><w:commentReference[^>]*/></w:r>",
"", text)
removed += n
text, n = re.subn(r"<w:r><w:rPr><w:vanish/></w:rPr>.*?</w:r>", "", text, flags=re.S)
removed += n
elif item.filename == "word/_rels/document.xml.rels":
text, n = re.subn(r'<Relationship [^>]*Target="[^"]*comments.xml"[^>]*/>', "", text)
removed += n
elif item.filename == "[Content_Types].xml":
text = re.sub(r'<Override PartName="/word/comments.xml"[^>]*/>', "", text)
zout.writestr(item.filename, text if text is not None else data)
return removed
src, dst = sys.argv[1], sys.argv[2]
print(f"{src}: {clean(src, dst)}件を取り除きました")
mkdir out
for f in b-core-owner c-tracked-delete d-comment e-hidden-run f-hidden-style g-external-link; do python3 clean_docx.py $f.docx out/$f.docx; done
python3 check_xml.py out/*.docx
echo "exit=$?"
b-core-owner.docx: 2件を取り除きました
c-tracked-delete.docx: 1件を取り除きました
d-comment.docx: 5件を取り除きました
e-hidden-run.docx: 1件を取り除きました
f-hidden-style.docx: 0件を取り除きました
g-external-link.docx: 0件を取り除きました
out/b-core-owner.docx: PASS
out/c-tracked-delete.docx: PASS
out/d-comment.docx: PASS
out/e-hidden-run.docx: PASS
out/f-hidden-style.docx: FAIL
- 非表示スタイル Memo
out/g-external-link.docx: FAIL
- 外部リンク file:///C:/Users/daisuke-pc/Desktop/invoice-old.xl
exit=1
4 件は直りましたが、f と g が残りました。f は、非表示のスタイル名を使っている run を消していません。g は、外部リンクのhyperlink要素と、リレーションの両方を消していません。清掃版も、検査と同じ穴を持っていました。
差分は 3 か所です。clean_v2.patchとして保存し、patchで当てました。
--- clean_docx.py
+++ clean_docx.py
@@ -6,6 +6,11 @@
def clean(src, dst):
removed = 0
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+ styles = zin.read("word/styles.xml").decode("utf-8")
+ hidden = re.findall(
+ r'<w:style [^>]*w:styleId="([^"]+)"[^>]*>(?:(?!</w:style>).)*<w:vanish/>', styles, flags=re.S)
+ rels = zin.read("word/_rels/document.xml.rels").decode("utf-8")
+ external = re.findall(r'Id="([^"]+)"[^>]*TargetMode="External"', rels)
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == "word/comments.xml":
@@ -24,9 +29,19 @@
removed += n
text, n = re.subn(r"<w:r><w:rPr><w:vanish/></w:rPr>.*?</w:r>", "", text, flags=re.S)
removed += n
+ for sid in hidden:
+ text, n = re.subn(
+ rf'<w:r><w:rPr><w:rStyle w:val="{sid}"/></w:rPr>.*?</w:r>', "", text, flags=re.S)
+ removed += n
+ for rid in external:
+ text, n = re.subn(
+ rf'<w:hyperlink r:id="{rid}">(.*?)</w:hyperlink>', r"\1", text, flags=re.S)
+ removed += n
elif item.filename == "word/_rels/document.xml.rels":
text, n = re.subn(r'<Relationship [^>]*Target="[^"]*comments.xml"[^>]*/>', "", text)
removed += n
+ text, n = re.subn(r'<Relationship [^>]*TargetMode="External"[^>]*/>', "", text)
+ removed += n
elif item.filename == "[Content_Types].xml":
text = re.sub(r'<Override PartName="/word/comments.xml"[^>]*/>', "", text)
zout.writestr(item.filename, text if text is not None else data)
patch clean_docx.py clean_v2.patch
rm -rf out && mkdir out
for f in b-core-owner c-tracked-delete d-comment e-hidden-run f-hidden-style g-external-link; do python3 clean_docx.py $f.docx out/$f.docx; done
python3 check_xml.py out/*.docx
echo "exit=$?"
patching file clean_docx.py
b-core-owner.docx: 2件を取り除きました
c-tracked-delete.docx: 1件を取り除きました
d-comment.docx: 5件を取り除きました
e-hidden-run.docx: 1件を取り除きました
f-hidden-style.docx: 1件を取り除きました
g-external-link.docx: 2件を取り除きました
out/b-core-owner.docx: PASS
out/c-tracked-delete.docx: PASS
out/d-comment.docx: PASS
out/e-hidden-run.docx: PASS
out/f-hidden-style.docx: PASS
out/g-external-link.docx: PASS
exit=0
6 件とも PASS になりました。本文が変わっていないかを、Pandoc の出力の差で確認します。
for f in b-core-owner c-tracked-delete d-comment e-hidden-run f-hidden-style g-external-link; do echo "== $f"; diff <(pandoc $f.docx -t plain) <(pandoc out/$f.docx -t plain) && echo 差分なし; done
== b-core-owner
差分なし
== c-tracked-delete
差分なし
== d-comment
差分なし
== e-hidden-run
5c5
< 備考プロンプト: 丁寧な文面で請求書を作って。社名はA社。
---
> 備考
== f-hidden-style
5c5
< 担当者内部メモ: 値引き上限は15%
---
> 担当者
== g-external-link
差分なし
e と f だけ差分が出ました。非表示にしていた「プロンプト」と「内部メモ」の行が消えています。これは狙いどおりです。それ以外の 4 件は、画面に出る本文が元と同じです。
変更履歴とコメントは、Pandoc のデフォルトでは本文に出ません。そこで出し方を変えて、実際に残っていたことも確かめました。
pandoc c-tracked-delete.docx -t plain | tail -1
pandoc --track-changes=all c-tracked-delete.docx -t plain | tail -1
pandoc d-comment.docx -t plain | tail -1
pandoc --track-changes=all d-comment.docx -t plain | tail -1
単価は4,980円です。
単価は9,800円(A社向け特別価格)4,980円です。
振込先は別紙のとおりです。
取引先ごとに口座を差し替える。前回の案件の口座番号のまま振込先は別紙のとおりです。
今回やって失敗したこと
最初の版は、作成者欄が空なら安全だと思い込んでいました。次の版は、文字列があれば止める作りにしたので、w:val="0"の表示される指定まで止めました。清掃も、検査と同じ穴を 2 つ残しました。3 回目でやっと、検査と清掃が同じ見方で XML を読む形になりました。
まだ残る穴も書いておきます。清掃は正規表現で<w:vanish/>だけを探しています。実物の Word が出力する、書式を複数持つ run には当たらない可能性があります。ヘッダー、フッター、docProps/app.xml、埋め込み画像の属性も見ていません。Word 本体で開いて確認もしていません。販売する完成品には、この検査を通した上で、Word の Document Inspector を併用するのが現実的です。
次に変えること
この検査を販売前の手順へ加える前に、Word で自作の請求書を保存し直し、同じ検査を通します。見本ではなく実物で何件止まるかを数えます。その結果をもとに、変更履歴を承認して保存し直す手順を、チェックリストの 1 行へ足します。
よくあるエラー
| 症状 | 原因 | 対処 |
|---|---|---|
KeyError: 'docProps/core.xml'が出ます | 保存元のアプリが core.xml を作っていません | 作成者情報がない状態として扱い、存在確認を足します |
KeyError: 'word/styles.xml'が出ます | 最小の docx で styles.xml がありません | zin.namelist()で存在を確かめてから読みます |
BadZipFileが出ます | docx ではないか、壊れたファイルです | Word で開き直して保存し直します |
| 検査が PASS なのに Pandoc の出力に履歴が出ます | Pandoc の--track-changes=allだけが見る場所が残っています | 検査側にw:insとw:delを足します |
| 外部リンクを消したのにリンクの文字が残ります | リレーションだけ消して、hyperlink要素を残しています | 本文のw:hyperlinkを外して、中の文字を残します |
| 清掃後に Word で開けません | リレーションを消したのに、本文が ID を参照しています | 参照元の要素も同時に外します |
出典と検証範囲
| 一次情報 | この回で確認した事項 |
|---|---|
| Microsoft Learn: DeletedRun Class | w:delが、変更履歴として記録された削除済みの内容を表す要素であること |
| Microsoft Learn: Vanish Class | w:vanishの要素名と、オン・オフ型であること |
| Microsoft Learn: Comments Class | コメント集合がw:commentsであること |
| Microsoft サポート: 文書を検査して非表示データと個人情報を削除する | 共有前に Document Inspector で調べる手段があること |
| Ecma International: ECMA-376 Office Open XML | docx のパッケージ構成と、core properties などの定義 |
| pandoc User’s Guide | --track-changes=allで変更履歴を出力に含められること |
Python zipfile | zip を展開せずに読み書きできること |
Python xml.etree.ElementTree | XML を解析して要素と属性を取り出せること |
実測したのは上の 8 つの手作り見本です。Word で保存した実物や、AI が出力した docx を測ったわけではありません。


