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

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()と@importdocxの中身。本文に見えない履歴やコメント
今回完了後新規: 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
                              │                    │
                              ▼                    ▼
                         [販売候補]       [除去 → 再検査 → 本文を比較]

用語

用語この回での意味
docxWord の文書形式です。XML を zip で束ねたファイルです
zip複数のファイルを 1 つにまとめる形式です。Python 標準のzipfileで読めます
XMLタグで構造を表す書式です。xml.etree.ElementTreeで解析します
名前空間XML のタグの語彙を区別する印です。Word のタグはw:の接頭辞で書かれます
core propertiesdocProps/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-runw:vanishで隠した制作時のプロンプト文
f-hidden-styleスタイルMemoの中のw:vanishで隠した内部メモ
g-external-link手元PCのファイルパスを指す外部リンク
h-vanish-offw: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 Classw:delが、変更履歴として記録された削除済みの内容を表す要素であること
Microsoft Learn: Vanish Classw:vanishの要素名と、オン・オフ型であること
Microsoft Learn: Comments Classコメント集合がw:commentsであること
Microsoft サポート: 文書を検査して非表示データと個人情報を削除する共有前に Document Inspector で調べる手段があること
Ecma International: ECMA-376 Office Open XMLdocx のパッケージ構成と、core properties などの定義
pandoc User’s Guide--track-changes=allで変更履歴を出力に含められること
Python zipfilezip を展開せずに読み書きできること
Python xml.etree.ElementTreeXML を解析して要素と属性を取り出せること

実測したのは上の 8 つの手作り見本です。Word で保存した実物や、AI が出力した docx を測ったわけではありません。

この記事をシェア