Files
Guido van Dijk 3e98ca373f fix(pptx): notitiemaster in presentation.xml, anders laat PowerPoint de notities weg
python-pptx schrijft het onderdeel wel maar noemt het niet in presentation.xml; nu wordt
notesMasterIdLst toegevoegd op de schemaplek, direct na sldMasterIdLst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:27:26 +02:00

149 lines
7.0 KiB
Python
Vendored

#!/usr/bin/env python3
"""Maakt een PowerPoint-bestand van een gebouwde presentatie uit deze repository.
Elke dia wordt als beeld op een 16:9-slide gezet en de sprekersnotities komen in het
notitieveld, zodat PowerPoint ze in de sprekersweergave op jouw scherm toont terwijl de zaal
alleen de dia ziet. Het beeld is een afbeelding: de opmaak is precies gelijk aan de PDF, maar
de tekst is in PowerPoint niet te bewerken. Wijzigen doe je in maak_dias.py en dan opnieuw
bouwen.
Gebruik: python3 ../_gereedschap/maak_pptx.py
vanuit de map van de presentatie (00-sessie, 00-lezing, 00-presentatie). Het script
zoekt zelf de dia-PDF, dus die van de dia's en niet de notitieversie. Staat er meer dan
een, geef dan de naam mee: python3 ../_gereedschap/maak_pptx.py sessie-agents-en-projecten.pdf
Staat er een notities-begeleider.json naast maak_dias.py, dan gebruikt het script die
uitgebreide notities en zegt het erbij; dat bestand is privé, dus deel die pptx niet.
Vereist: pdftoppm (Poppler) en python-pptx.
"""
import json
import re
import zipfile
import subprocess
import sys
import tempfile
from pathlib import Path
try:
from pptx import Presentation
from pptx.util import Inches
except ImportError:
sys.exit("python-pptx ontbreekt. Bijvoorbeeld: ~/.local/venvs/uitlegvideo/bin/python "
"../_gereedschap/maak_pptx.py <dia-pdf>")
def zoek_dia_pdf(waar: Path) -> Path:
"""De PDF van de dia's in deze map: zonder -met-notities in de naam."""
if not (waar / "maak_dias.py").exists():
sys.exit(f"{waar} is geen presentatiemap. Draai dit vanuit 00-sessie, 00-lezing of "
f"00-presentatie, of geef het pad naar de dia-PDF mee.")
kandidaten = [f for f in sorted(waar.glob("*.pdf")) if not f.stem.endswith("-met-notities")]
if not kandidaten:
sys.exit(f"geen dia-PDF in {waar.name}; bouw hem eerst met python3 bouw_pdf.py")
if len(kandidaten) > 1:
namen = ", ".join(f.name for f in kandidaten[:5])
sys.exit(f"meerdere PDF's in {waar.name} ({namen}); geef de naam van de dia-PDF mee")
return kandidaten[0]
if len(sys.argv) > 1:
pdf = Path(sys.argv[1]).resolve()
if not pdf.exists():
sys.exit(f"niet gevonden: {pdf}")
if pdf.stem.endswith("-met-notities"):
sys.exit(f"{pdf.name} is de notitieversie. Geef de PDF van de dia's, of laat het argument weg.")
else:
pdf = zoek_dia_pdf(Path.cwd())
MAP = pdf.parent
if not (MAP / "maak_dias.py").exists():
sys.exit(f"geen maak_dias.py in {MAP}; draai dit vanuit de map van een presentatie")
P = MAP / "project"
PRIVE = (MAP / "notities-begeleider.json").exists()
# 1. dia's en PDF opnieuw bouwen, zodat beeld en notities bij elkaar horen
subprocess.run([sys.executable, str(MAP / "maak_dias.py")] + (["--notities"] if PRIVE else []),
check=True, capture_output=True)
deck = json.loads((P / "deck.json").read_text(encoding="utf-8"))
faces = "".join(f'<link rel="stylesheet" href="{f["href"]}">' for f in deck["faces"].values())
tmp = Path(tempfile.mkdtemp(prefix="pptx-"))
css = """
@page { size: 1920px 1080px; margin: 0; }
html, body { margin: 0; padding: 0; }
* { box-sizing: border-box; margin: 0; padding: 0; }
section { position: relative; width: 1920px; height: 1080px; overflow: hidden; page-break-after: always; break-after: page; }
section:last-child { page-break-after: auto; }
aside { display: none; }
ul { padding-left: 1.1em; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #D8D2C4; padding: 12px 18px; text-align: left; vertical-align: top; }
img { max-width: 100%; }
"""
(tmp / "dias.html").write_text(
f'<!DOCTYPE html><html lang="nl"><head><meta charset="utf-8">{faces}<style>{css}</style></head><body>'
+ "".join((P / "slides" / f"{sid}.html").read_text(encoding="utf-8") for sid in deck["order"])
+ "</body></html>", encoding="utf-8")
subprocess.run(["google-chrome", "--headless=new", "--disable-gpu", "--no-pdf-header-footer",
"--virtual-time-budget=8000", f"--print-to-pdf={tmp / 'dias.pdf'}", (tmp / "dias.html").as_uri()],
check=True, capture_output=True)
# 2. elke pagina als beeld
subprocess.run(["pdftoppm", "-r", "100", "-jpeg", "-jpegopt", "quality=88",
str(tmp / "dias.pdf"), str(tmp / "dia")], check=True)
beelden = sorted(tmp.glob("dia-*.jpg"))
if len(beelden) != len(deck["order"]):
sys.exit(f"{len(beelden)} beelden voor {len(deck['order'])} dia's; bouw de dia's opnieuw")
def notities(sid):
tekst = re.search(r"<aside>(.*?)</aside>",
(P / "slides" / f"{sid}.html").read_text(encoding="utf-8"), re.S)
tekst = (tekst.group(1).strip() if tekst else "")
tekst = re.sub(r"<[^>]+>", "", tekst)
# de privénotities staan als BEDOELING: ... | ZEGGEN: ... op één regel; leesbaar maken
return "\n\n".join(d.strip() for d in tekst.split(" | ") if d.strip())
# 3. de pptx: 16:9, elk beeld schermvullend, notities in het notitieveld
prs = Presentation()
prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
leeg = prs.slide_layouts[6] # blank
for n, sid in enumerate(deck["order"]):
dia = prs.slides.add_slide(leeg)
dia.shapes.add_picture(str(beelden[n]), 0, 0, width=prs.slide_width, height=prs.slide_height)
dia.notes_slide.notes_text_frame.text = notities(sid)
uit = pdf.with_suffix(".pptx")
prs.save(str(uit))
def repareer_notitiemaster(pad: Path) -> bool:
"""python-pptx schrijft de notitiemaster wel als onderdeel, maar noemt hem niet in
presentation.xml. PowerPoint laat de notities dan weg. Deze reparatie zet de ontbrekende
regel erin, op de plek die het schema voorschrijft: direct na de dia-master."""
with zipfile.ZipFile(pad) as bron:
pres = bron.read("ppt/presentation.xml").decode("utf-8")
if "notesMasterIdLst" in pres:
return False
rels = bron.read("ppt/_rels/presentation.xml.rels").decode("utf-8")
m = re.search(r'Id="([^"]+)"[^>]*notesMaster[^>]*/>', rels)
if not m:
return False
pres = pres.replace(
"</p:sldMasterIdLst>",
f'</p:sldMasterIdLst><p:notesMasterIdLst><p:notesMasterId r:id="{m.group(1)}"/>'
f"</p:notesMasterIdLst>", 1)
onderdelen = [(i, bron.read(i.filename)) for i in bron.infolist()]
tijdelijk = pad.with_suffix(".tmp")
with zipfile.ZipFile(tijdelijk, "w", zipfile.ZIP_DEFLATED) as nieuw_zip:
for info, data in onderdelen:
nieuw_zip.writestr(info, pres.encode("utf-8") if info.filename == "ppt/presentation.xml" else data)
tijdelijk.replace(pad)
return True
gerepareerd = repareer_notitiemaster(uit)
# 4. de dia-bestanden terugzetten op de publieke versie
subprocess.run([sys.executable, str(MAP / "maak_dias.py")], check=True, capture_output=True)
soort = "uitgebreide begeleidersnotities (privé, niet delen)" if PRIVE else "de notities uit de dia's"
print(f"{uit.name}: {uit.stat().st_size // 1024} kB, {len(deck['order'])} dia's, met {soort}"
+ (", notitiemaster toegevoegd" if gerepareerd else ""))