python-pptxで作った提案資料に、自分で入れていない更新者名と「4:3」が残っていた

python-pptxで作った提案資料に、自分で入れていない更新者名と「4:3」が残っていた

cp:lastModifiedBy = Steve Canny

提案資料テンプレートの「最終更新者」に入っていた値です。私はこの名前を一度も打っていません。python-pptxというライブラリのデフォルトの雛形が、黙って持ち込んでいました。

売るつもりのpptxを配る前に調べた記録です。実在の商品は使っていません。コードで組んだ合成見本が相手です。PowerPointで開いた確認はしていません。

疑っていたのは2か所だけだった

見本は3枚です。2枚目のノートに値引きは10%まで。それ以上は断る。、3枚目に仕入れ原価は定価の42%を書き、3枚目は非表示にしました。

売り物のpptxで事故になりやすいのは、この2か所です。PowerPointのドキュメント検査にも、プレゼンテーションノートとスライド外のコンテンツが項目として並んでいます。

作業は空のディレクトリpptx-checkで行っています。

python3 --version
.venv/bin/python -c "import pptx; print(pptx.__version__)"
Python 3.13.5
1.0.2

.venvはpython3 -m venv .venvで作り、.venv/bin/pip install python-pptxで入れたものです。

make_sample.pyの全文
from pptx import Presentation
from pptx.util import Inches

prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)

s1 = prs.slides.add_slide(prs.slide_layouts[0])
s1.shapes.title.text = "提案資料テンプレート"
s1.placeholders[1].text = "会社名をここに入れてください"

s2 = prs.slides.add_slide(prs.slide_layouts[1])
s2.shapes.title.text = "課題の整理"
s2.placeholders[1].text = "現状の3つの課題を書く"
s2.notes_slide.notes_text_frame.text = "値引きは10%まで。それ以上は断る。"

s3 = prs.slides.add_slide(prs.slide_layouts[1])
s3.shapes.title.text = "原価メモ"
s3.placeholders[1].text = "仕入れ原価は定価の42%"
s3._element.set("show", "0")  # 非表示スライド

prs.save("sample-deck.pptx")
print("saved", len(prs.slides), "slides")
.venv/bin/python make_sample.py
saved 3 slides

画面の大きさは13.333×7.5インチ、つまりワイド画面にしています。python-pptxのデフォルトの雛形は4:3なので、ここだけは自分で変えました。

検査スクリプトを通したら9件

pptxは、XMLファイルを詰めたzipです。docProps/core.xmlには作成者や最終更新者、docProps/app.xmlにはスライド枚数や画面比率の自己申告が入っています。docProps/thumbnail.jpegはファイルの一覧で出るプレビュー画像です。

この3つとノート、非表示スライド、コメント、外部リンクを、標準ライブラリだけで読む検査スクリプトを書きました。非表示スライドは、スライドのXMLにあるshow="0"で判定します。

check_pptx.pyの全文
import re, sys, zipfile
import xml.etree.ElementTree as ET

NS = {
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
    "dc": "http://purl.org/dc/elements/1.1/",
    "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
    "dcterms": "http://purl.org/dc/terms/",
    "ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties",
}

def check(path, allow=()):
    z = zipfile.ZipFile(path)
    names = z.namelist()
    found = []

    slides = sorted(n for n in names if re.fullmatch(r"ppt/slides/slide\d+\.xml", n))
    for n in slides:
        root = ET.fromstring(z.read(n))
        if root.get("show") == "0":
            found.append(f"hidden-slide  {n}")

    for n in sorted(x for x in names if re.fullmatch(r"ppt/notesSlides/notesSlide\d+\.xml", x)):
        text = "".join(t.text or "" for t in ET.fromstring(z.read(n)).iter(f"{{{NS['a']}}}t"))
        if text.strip():
            found.append(f"notes         {n}: {text.strip()[:30]}")

    core = ET.fromstring(z.read("docProps/core.xml"))
    for tag in ("dc:creator", "cp:lastModifiedBy", "dc:description"):
        v = (core.findtext(tag, namespaces=NS) or "").strip()
        if v and v not in allow:
            found.append(f"core          {tag} = {v}")
    created = core.findtext("dcterms:created", namespaces=NS) or ""
    if created[:4] and int(created[:4]) < 2020:
        found.append(f"core          created = {created} (template date)")

    sz = ET.fromstring(z.read("ppt/presentation.xml")).find("p:sldSz", NS)
    cx, cy = int(sz.get("cx")), int(sz.get("cy"))
    app = ET.fromstring(z.read("docProps/app.xml"))
    fmt = app.findtext("ep:PresentationFormat", namespaces=NS) or ""
    if "4:3" in fmt and cx / cy > 1.5:
        found.append(f"app           PresentationFormat = {fmt} but size is {cx}x{cy}")
    if sz.get("type") == "screen4x3" and cx / cy > 1.5:
        found.append(f"presentation  sldSz type=screen4x3 but size is {cx}x{cy}")
    n_app = int(app.findtext("ep:Slides", namespaces=NS) or 0)
    if n_app != len(slides):
        found.append(f"app           Slides = {n_app} but {len(slides)} slide files")

    if "docProps/thumbnail.jpeg" in names:
        found.append(f"thumbnail     docProps/thumbnail.jpeg ({z.getinfo('docProps/thumbnail.jpeg').file_size} bytes)")

    for n in names:
        if n.startswith("ppt/comments/") or n == "ppt/commentAuthors.xml":
            found.append(f"comments      {n}")
        if n.endswith(".rels"):
            for rel in ET.fromstring(z.read(n)):
                if rel.get("TargetMode") == "External":
                    found.append(f"external-link {n}: {rel.get('Target')}")
    return found

if __name__ == "__main__":
    allow = tuple(sys.argv[2:])
    items = check(sys.argv[1], allow)
    for i in items:
        print(i)
    print(f"{len(items)} findings")
    sys.exit(1 if items else 0)
python3 check_pptx.py sample-deck.pptx; echo "exit=$?"
hidden-slide  ppt/slides/slide3.xml
notes         ppt/notesSlides/notesSlide1.xml: 値引きは10%まで。それ以上は断る。
core          cp:lastModifiedBy = Steve Canny
core          dc:description = generated using python-pptx
core          created = 2013-01-27T09:14:16Z (template date)
app           PresentationFormat = On-screen Show (4:3) but size is 12191695x6858000
presentation  sldSz type=screen4x3 but size is 12191695x6858000
app           Slides = 0 but 3 slide files
thumbnail     docProps/thumbnail.jpeg (4067 bytes)
9 findings
exit=1

仕込んだ2件(hidden-slideとnotes)は当たり。残りの7件は、私が何も書いていない場所でした。

検出中身仕込んだか
hidden-slide3枚目が非表示仕込んだ
notes値引きの下限メモ仕込んだ
core最終更新者が別人の名前していない
core説明欄にgenerated using python-pptxしていない
core作成日が2013-01-27していない
app / presentation画面は16:9なのに4:3の表記が2か所していない
appSlides = 0(実際は3枚)していない
thumbnail4067バイトのプレビュー画像していない

サムネイルは取り出して開いてみました。

unzip -p sample-deck.pptx docProps/thumbnail.jpeg > thumb.jpg
identify thumb.jpg
thumb.jpg JPEG 256x192 256x192+0+0 8-bit sRGB 4067B 0.000u 0:00.000

256×192の4:3で、中は白一色でした。ワイド画面に直したスライドの絵ではなく、雛形の空のスライドを撮ったものです。

4:3の表記が残っていても、スライド自体は13.333×7.5インチで描かれるはずです。ただ、購入者の使うアプリがどちらを信じるかは確かめていません。ここは宿題です。

1回目の掃除はAPIだけで済ませようとした

python-pptxのドキュメントで書き込めると案内されているのは、作成者や最終更新者などのcore propertiesです。まずその範囲で直しました。非表示スライドは外し、ノートは空にし、更新者と作成日を自分の値に置き換えます。

clean_pptx.pyの全文
import datetime as dt
from pptx import Presentation

prs = Presentation("sample-deck.pptx")

# 非表示スライドを外す
sld_ids = prs.slides._sldIdLst
for sld_id in list(sld_ids):
    slide = prs.slides.get(int(sld_id.get("id")))
    if slide._element.get("show") == "0":
        prs.part.drop_rel(sld_id.rId)
        sld_ids.remove(sld_id)

# ノートを空にする
for slide in prs.slides:
    if slide.has_notes_slide:
        slide.notes_slide.notes_text_frame.text = ""

# コア情報を自分の値にそろえる
cp = prs.core_properties
now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0, tzinfo=None)
cp.author = "hibanas"
cp.last_modified_by = "hibanas"
cp.comments = ""
cp.created = now
cp.modified = now
cp.title = "提案資料テンプレート"

prs.save("clean-deck.pptx")
print("saved", len(prs.slides), "slides")
.venv/bin/python clean_pptx.py
python3 check_pptx.py clean-deck.pptx; echo "exit=$?"
saved 2 slides
core          dc:creator = hibanas
core          cp:lastModifiedBy = hibanas
app           PresentationFormat = On-screen Show (4:3) but size is 12191695x6858000
presentation  sldSz type=screen4x3 but size is 12191695x6858000
app           Slides = 0 but 2 slide files
thumbnail     docProps/thumbnail.jpeg (4067 bytes)
6 findings
exit=1

9件が6件へ。ただし、6件のうち2件は、いま私が入れたhibanasという名前でした。検査が自分の名前まで指摘してきたのです。直すべきは名前ではなく、検査のほうでした。最初の版は、値が空でなければ全部報告していました。

上に載せたスクリプトは、この後に直した版です。引数に許可する名前を渡せるようにしてあります。

python3 check_pptx.py clean-deck.pptx hibanas; echo "exit=$?"
app           PresentationFormat = On-screen Show (4:3) but size is 12191695x6858000
presentation  sldSz type=screen4x3 but size is 12191695x6858000
app           Slides = 0 but 2 slide files
thumbnail     docProps/thumbnail.jpeg (4067 bytes)
4 findings
exit=1

残った4件は、app.xmlとプレゼンテーション本体のsldSz、サムネイルです。ドキュメントのそのページには、これらを書き換える項目は見当たりませんでした。

残りの4件はzipを書き換えた

APIで届かない以上、zipを直接開くしかありません。やったことは3つです。

  • app.xmlを、枚数と画面比率だけを入れた最小のものに置き換える
  • ppt/presentation.xmlからtype="screen4x3"を外す
  • サムネイルのファイルと、その参照(_rels/.relsの1行)を消す
finalize_pptx.pyの全文
import re, sys, zipfile

src, dst = sys.argv[1], sys.argv[2]
zin = zipfile.ZipFile(src)
n_slides = len([n for n in zin.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)])
n_notes = len([n for n in zin.namelist() if re.fullmatch(r"ppt/notesSlides/notesSlide\d+\.xml", n)])

APP = (
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
    '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">'
    f"<Slides>{n_slides}</Slides><Notes>{n_notes}</Notes><HiddenSlides>0</HiddenSlides>"
    "<PresentationFormat>Widescreen</PresentationFormat></Properties>"
)

with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
    for item in zin.infolist():
        if item.filename == "docProps/thumbnail.jpeg":
            continue
        data = zin.read(item.filename)
        if item.filename == "docProps/app.xml":
            data = APP.encode("utf-8")
        elif item.filename == "ppt/presentation.xml":
            data = data.replace(b' type="screen4x3"', b"")
        elif item.filename == "_rels/.rels":
            data = re.sub(rb'<Relationship [^>]*metadata/thumbnail[^>]*/>', b"", data)
        zout.writestr(item.filename, data)
print("written", dst)
python3 finalize_pptx.py clean-deck.pptx final-deck.pptx
python3 check_pptx.py final-deck.pptx hibanas; echo "exit=$?"
written final-deck.pptx
0 findings
exit=0

0件。python-pptxで開き直せることと、中身が壊れていないことも見ます。

.venv/bin/python -c "
from pptx import Presentation
p = Presentation('final-deck.pptx')
print(len(p.slides), [s.shapes.title.text for s in p.slides])"
2 ['提案資料テンプレート', '課題の整理']

消したはずの文字列が、zipのどこにも残っていないことも確かめました。ノートの文面と非表示スライドの原価です。

python3 - <<'EOF'
import zipfile
for f in ("sample-deck.pptx", "final-deck.pptx"):
    z = zipfile.ZipFile(f)
    for word in ("値引き", "42%"):
        print(f, word, [n for n in z.namelist() if word.encode() in z.read(n)])
EOF
sample-deck.pptx 値引き ['ppt/notesSlides/notesSlide1.xml']
sample-deck.pptx 42% ['ppt/slides/slide3.xml']
final-deck.pptx 値引き []
final-deck.pptx 42% []

元のファイルでは、値引きがノートに、42%が3枚目のスライドに残っています。最終版はどちらも空です。

検査が何も拾わない状態になっていないか

0件は、検査が壊れていても出る数字です。本当に拾えるのか。そこで、最終版にわざと外部リンクを1つ足したファイルを作りました。example.invalidは実在しない名前に使える予約済みのドメインです。

.venv/bin/python - <<'EOF'
from pptx import Presentation
p = Presentation("final-deck.pptx")
tf = p.slides[1].placeholders[1].text_frame
r = tf.paragraphs[0].add_run(); r.text = " 詳細"; r.hyperlink.address = "https://example.invalid/draft-price"
p.save("link-control.pptx")
EOF
python3 check_pptx.py link-control.pptx hibanas; echo "exit=$?"
external-link ppt/slides/_rels/slide2.xml.rels: https://example.invalid/draft-price
1 findings
exit=1

外部リンクは拾えています。コメントの分岐は、コメント入りのファイルを作っていないので動作を見ていません。

納得できていないこと

サムネイルは削除しました。白一色のまま残さず、プレビューなしにしています。販売サイトや購入者のファイラーで「プレビューなし」がどう表示されるかは、確かめていません。空のアイコンが並ぶだけの表示なら、白紙のサムネイルを残す選択もありました。

もう1つ、app.xmlを最小にしたことも、PowerPointで開いて確かめていません。python-pptxで読み直せただけです。

コードでpptxを作るとき、python-pptxは最初に候補へ上がります。デフォルトの雛形の中身は、自分で書いたコードのどこにも出てきません。ノートと非表示スライドだけ見ていたら、7件は通り抜けていました。

確認できていないこと

  • PowerPoint、Keynote、Googleスライド、LibreOfficeのどれでも開いていません。final-deck.pptxが修復の案内なしで開くかは不明です
  • PowerPointのドキュメント検査と、今回の検査スクリプトの結果は比べていません
  • スライド外に置いた図形、画像のメタデータ、カスタムXMLは見ていません。コメントの検査は動作未確認です
  • python-pptx 1.0.2のデフォルトの雛形だけの結果です。別のバージョンや別のライブラリは試していません

出典

この記事をシェア