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-slide | 3枚目が非表示 | 仕込んだ |
| notes | 値引きの下限メモ | 仕込んだ |
| core | 最終更新者が別人の名前 | していない |
| core | 説明欄にgenerated using python-pptx | していない |
| core | 作成日が2013-01-27 | していない |
| app / presentation | 画面は16:9なのに4:3の表記が2か所 | していない |
| app | Slides = 0(実際は3枚) | していない |
| thumbnail | 4067バイトのプレビュー画像 | していない |
サムネイルは取り出して開いてみました。
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-pptx1.0.2のデフォルトの雛形だけの結果です。別のバージョンや別のライブラリは試していません


