3e98ca373f
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>
149 lines
7.0 KiB
Python
149 lines
7.0 KiB
Python
#!/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 ""))
|