Add helloprint-presentation-creator skill — generate_presentation.py

This commit is contained in:
2026-04-16 11:12:31 +00:00
parent 9e332f74d3
commit cfbfda2806
@@ -0,0 +1,530 @@
"""
Helloprint Presentation Generator — Keep-and-Delete Engine v2
-------------------------------------------------------------
Strategy: copy full baseline → remove unwanted slides → replace text.
All original file numbers / internal references stay intact.
Unused media files are stripped. Slide order matches slide_plan order.
NEW in v2:
- Auto page numbering (replaces hardcoded "03" footer numbers)
- Nth-occurrence replacement: (old, new, n) replaces only the nth match
- Duplicate template support: use same template multiple times in one deck
- Media replacement: swap image files via generate(..., media={})
Slide index map (0-based, matches presentation order in baseline):
0 title-dark 8 process-timeline
1 hero-split-photo 9 before-after
2 content-bullets-kpis 10 big-number
3 impact-statement 11 section-divider
4 chart-kpi-cards 12 closing
5 two-column-product 13 agenda
6 usp-4-cards 14 testimonial
7 team-photo
"""
import zipfile, re, os
from io import BytesIO
BASELINE = "/sessions/brave-bold-cerf/hp_baseline.pptx"
SLIDE_IDX = {
"title-dark": 0,
"hero-split-photo": 1,
"content-bullets-kpis": 2,
"impact-statement": 3,
"chart-kpi-cards": 4,
"two-column-product": 5,
"usp-4-cards": 6,
"team-photo": 7,
"process-timeline": 8,
"before-after": 9,
"big-number": 10,
"section-divider": 11,
"closing": 12,
"agenda": 13,
"testimonial": 14,
}
SLIDE_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
SLIDE_CT = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
IMAGE_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
# Slides that display a footer page number (sz=600 small text in bottom-right)
SLIDES_WITH_PAGE_NUM = {
"content-bullets-kpis", "chart-kpi-cards", "two-column-product",
"usp-4-cards", "process-timeline", "before-after", "agenda",
}
# ── Core helpers ──────────────────────────────────────────────────────────────
def get_slide_file_order(src_zip):
"""Returns list of (slide_path, rels_path, rId) in presentation order."""
prs_xml = src_zip.read('ppt/presentation.xml').decode('utf-8')
prs_rels = src_zip.read('ppt/_rels/presentation.xml.rels').decode('utf-8')
rid_order = re.findall(r'<p:sldId\b[^>]*r:id="([^"]+)"', prs_xml)
rid_to_target = {}
for m in re.finditer(
r'<Relationship\s+Id="([^"]+)"\s+Type="([^"]+)"\s+Target="([^"]+)"', prs_rels
):
if m.group(2) == SLIDE_REL:
rid_to_target[m.group(1)] = m.group(3)
order = []
for rid in rid_order:
if rid in rid_to_target:
target = rid_to_target[rid]
slide_path = 'ppt/' + target
rels_path = re.sub(
r'ppt/slides/(slide\d+\.xml)',
r'ppt/slides/_rels/\1.rels',
slide_path
)
order.append((slide_path, rels_path, rid))
return order
def collect_used_media(src_zip, drop_paths):
"""
Collect media files referenced from any rels file NOT in drop_paths.
Returns set of ZIP paths like 'ppt/media/image1.png'.
"""
used = set()
for name in src_zip.namelist():
if name in drop_paths or not name.endswith('.rels'):
continue
try:
rels_xml = src_zip.read(name).decode('utf-8')
except Exception:
continue
rels_dir = name.rsplit('/', 1)[0]
part_dir = rels_dir.rsplit('/', 1)[0]
for m in re.finditer(r'Target="([^"]+)"', rels_xml):
target = m.group(1)
if target.startswith('http') or target.startswith('#'):
continue
resolved = os.path.normpath(part_dir + '/' + target).replace('\\', '/')
if '/media/' in resolved:
used.add(resolved)
return used
def replace_nth(s, old, new, n):
"""Replace only the nth occurrence (1-indexed) of old with new."""
count, idx = 0, 0
while True:
pos = s.find(old, idx)
if pos == -1:
break
count += 1
if count == n:
return s[:pos] + new + s[pos + len(old):]
idx = pos + 1
return s # nth occurrence not found — leave unchanged
def replace_text(xml_bytes, replacements):
"""
Apply text replacements to slide XML.
replacements: list of (old, new) or (old, new, n)
- (old, new) → replaces ALL occurrences
- (old, new, n) → replaces the nth occurrence (1-indexed, logical card/row number)
Handles XML escaping automatically — pass raw & characters, not &amp;
nth-occurrence tracking: when multiple entries target the same old_text with
different n values (e.g. cards 1-4), the engine adjusts for already-consumed
occurrences so n=2 always means 'the 2nd card', not 'the 2nd remaining one'.
"""
s = xml_bytes.decode('utf-8')
# Track how many nth-replacements have been applied per old_text,
# so later entries with higher n are adjusted to target the correct occurrence.
consumed: dict = {}
for entry in replacements:
old, new = entry[0], entry[1]
occurrence = entry[2] if len(entry) > 2 else None
old_esc = old.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
new_esc = new.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
if occurrence is None:
# Replace all occurrences (and reset any consumed counter for this key)
s = s.replace(f'>{old_esc}<', f'>{new_esc}<')
s = s.replace(f'>{old}<', f'>{new_esc}<')
consumed.pop(old, None)
else:
# Adjust n: subtract how many occurrences of this text were already replaced
already = consumed.get(old, 0)
adjusted = occurrence - already
if adjusted >= 1:
replaced_esc = replace_nth(s, f'>{old_esc}<', f'>{new_esc}<', adjusted)
if replaced_esc != s:
s = replaced_esc
else:
s = replace_nth(s, f'>{old}<', f'>{new_esc}<', adjusted)
consumed[old] = already + 1
return s.encode('utf-8')
def apply_page_number(xml_bytes, page_num):
"""
Replace the footer page number in slide XML.
Footer numbers are identified by font size sz="600" (6pt) — distinct from
larger decorative numbers like agenda item badges (sz="1000"+).
"""
s = xml_bytes.decode('utf-8')
num = f"{page_num:02d}"
def replacer(m):
# Check the 300 chars before this text run for sz="600" (footer font size)
start = max(0, m.start() - 300)
context = s[start:m.start()]
# Only replace if this is footer-size text
if re.search(r'sz="600"', context):
return f'>{num}<'
return m.group(0)
s = re.sub(r'>0[1-9]<', replacer, s)
return s.encode('utf-8')
# ── Main generator ────────────────────────────────────────────────────────────
def generate(slide_plan, output_path, media=None, page_numbers=True):
"""
Generate a Helloprint presentation.
slide_plan : list of (template_name, replacements)
replacements items: (old, new) or (old, new, n) for nth-occurrence
output_path : destination .pptx file path
media : dict mapping baseline image filename → local file path
e.g. {"image20.jpg": "/path/to/office_photo.jpg"}
Use for slides with photo slots: hero-split-photo (image20.jpg),
team-photo (image21.jpg), two-column-product (add your own).
page_numbers: if True (default), auto-number slide footers
"""
if media is None:
media = {}
with zipfile.ZipFile(BASELINE, 'r') as src:
slide_order = get_slide_file_order(src)
total = len(slide_order)
# Find max existing slide number for naming copies
existing_nums = [
int(re.search(r'slide(\d+)\.xml', s[0]).group(1))
for s in slide_order
if re.search(r'slide(\d+)\.xml', s[0])
]
next_copy_num = max(existing_nums, default=21) + 100 # safe gap
# ── Build wanted dict + plan_order ────────────────────────────────────
# wanted maps baseline_idx → (slide_info, replacements, template)
# copies maps copy_rid → (source_slide_info, replacements, template, copy_num)
wanted = {}
copies = {} # extra slides for duplicate templates
plan_order = [] # rIds in slide_plan order (for sldIdLst reordering)
seen_rids = set()
copy_counter = 0
for template, replacements in slide_plan:
idx = SLIDE_IDX.get(template)
if idx is None:
print(f" ✗ Unknown template: {template}")
continue
if idx >= total:
print(f" ✗ Index {idx} out of range for {template}")
continue
if idx not in wanted:
# First use of this template
wanted[idx] = (slide_order[idx], replacements, template)
rId = slide_order[idx][2]
plan_order.append(rId)
seen_rids.add(rId)
else:
# Duplicate — create a copy with a new filename and rId
copy_num = next_copy_num + copy_counter
copy_counter += 1
copy_rid = f"rIdCopy{copy_num}"
copies[copy_rid] = (slide_order[idx], replacements, template, copy_num)
plan_order.append(copy_rid)
seen_rids.add(copy_rid)
print(f" ↳ Duplicate '{template}' → new slide{copy_num}.xml")
# Slide number lookup: rId → 1-based position in output
slide_num_map = {rId: i + 1 for i, rId in enumerate(plan_order)}
# ── Path sets ─────────────────────────────────────────────────────────
keep_paths = {slide_order[i][0] for i in wanted}
keep_rids = {slide_order[i][2] for i in wanted}
drop_paths = (
{slide_order[i][0] for i in range(total) if i not in wanted} |
{slide_order[i][1] for i in range(total) if i not in wanted}
)
used_media = collect_used_media(src, drop_paths)
out_buf = BytesIO()
with zipfile.ZipFile(out_buf, 'w', zipfile.ZIP_DEFLATED) as dst:
for item in src.namelist():
# ── Drop unwanted slide XML + rels ────────────────────────────
if item in drop_paths:
continue
# ── Strip unused media; apply optional overrides ───────────────
if item.startswith('ppt/media/'):
fname = item.split('/')[-1]
if item not in used_media:
continue
if fname in media:
# User-provided replacement image
with open(media[fname], 'rb') as f:
dst.writestr(item, f.read())
print(f" 🖼 Replaced {fname}")
else:
dst.writestr(item, src.read(item))
continue
# ── Process kept slides ───────────────────────────────────────
if item in keep_paths:
for idx, (info, replacements, template) in wanted.items():
if info[0] == item:
data = replace_text(src.read(item), replacements)
if page_numbers and template in SLIDES_WITH_PAGE_NUM:
rId = info[2]
pg = slide_num_map.get(rId, 0)
data = apply_page_number(data, pg)
dst.writestr(item, data)
print(f" ✓ {template:30s} ({item})")
break
continue
# ── Patch ppt/presentation.xml — reorder sldIdLst + add copies ─
if item == 'ppt/presentation.xml':
data = src.read(item).decode('utf-8')
rid_to_elem = {}
existing_ids = []
for m in re.finditer(r'<p:sldId\b[^/]*/>', data):
rid_m = re.search(r'r:id="([^"]+)"', m.group(0))
id_m = re.search(r'\bid="(\d+)"', m.group(0))
if rid_m:
rid_to_elem[rid_m.group(1)] = m.group(0)
if id_m:
existing_ids.append(int(id_m.group(1)))
next_sld_id = max(existing_ids, default=256) + 1
for copy_rid, (src_info, _, _, copy_num) in copies.items():
elem = (
f'<p:sldId id="{next_sld_id}"'
f' r:id="{copy_rid}"/>'
)
rid_to_elem[copy_rid] = elem
next_sld_id += 1
ordered = ''.join(
rid_to_elem[rid] for rid in plan_order if rid in rid_to_elem
)
data = re.sub(
r'(<p:sldIdLst>)(.*?)(</p:sldIdLst>)',
lambda m: m.group(1) + ordered + m.group(3),
data, flags=re.DOTALL
)
dst.writestr(item, data.encode('utf-8'))
continue
# ── Patch _rels/presentation.xml.rels — add copy relationships ─
if item == 'ppt/_rels/presentation.xml.rels':
data = src.read(item).decode('utf-8')
def keep_rel(m):
rid = re.search(r'Id="([^"]+)"', m.group(0))
rtype = re.search(r'Type="([^"]+)"', m.group(0))
if rtype and rtype.group(1) == SLIDE_REL:
return m.group(0) if (rid and rid.group(1) in keep_rids) else ''
return m.group(0)
data = re.sub(r'<Relationship[^>]*/>', keep_rel, data)
copy_rels = ''.join(
f'<Relationship Id="{copy_rid}" '
f'Type="{SLIDE_REL}" '
f'Target="slides/slide{copy_num}.xml"/>'
for copy_rid, (_, _, _, copy_num) in copies.items()
)
data = data.replace('</Relationships>', copy_rels + '</Relationships>')
dst.writestr(item, data.encode('utf-8'))
continue
# ── Patch [Content_Types].xml — add copy overrides ────────────
if item == '[Content_Types].xml':
data = src.read(item).decode('utf-8')
def keep_ct(m):
ct_type = re.search(r'ContentType="([^"]+)"', m.group(0))
part = re.search(r'PartName="([^"]+)"', m.group(0))
if ct_type and ct_type.group(1) == SLIDE_CT:
if part:
fname = part.group(1).lstrip('/')
return m.group(0) if fname in keep_paths else ''
return m.group(0)
data = re.sub(r'<Override[^>]*/>', keep_ct, data)
copy_cts = ''.join(
f'<Override PartName="/ppt/slides/slide{copy_num}.xml"'
f' ContentType="{SLIDE_CT}"/>'
for _, (_, _, _, copy_num) in copies.items()
)
data = data.replace('</Types>', copy_cts + '</Types>')
dst.writestr(item, data.encode('utf-8'))
continue
# ── Copy everything else verbatim ─────────────────────────────
dst.writestr(item, src.read(item))
# ── Write copied slide files ──────────────────────────────────────
for copy_rid, (src_info, replacements, template, copy_num) in copies.items():
src_path = src_info[0]
src_rels = src_info[1]
dest_path = f'ppt/slides/slide{copy_num}.xml'
dest_rels = f'ppt/slides/_rels/slide{copy_num}.xml.rels'
data = replace_text(src.read(src_path), replacements)
if page_numbers and template in SLIDES_WITH_PAGE_NUM:
pg = slide_num_map.get(copy_rid, 0)
data = apply_page_number(data, pg)
dst.writestr(dest_path, data)
print(f" ✓ {template:30s} (copy → slide{copy_num}.xml)")
if src_rels in src.namelist():
dst.writestr(dest_rels, src.read(src_rels))
with open(output_path, 'wb') as f:
f.write(out_buf.getvalue())
n_total = len(wanted) + len(copies)
print(f"\n✓ {n_total} slides → {output_path}")
size_mb = os.path.getsize(output_path) / 1024 / 1024
print(f" File size: {size_mb:.1f} MB")
# ── Example: Rotterdam monthly overview ──────────────────────────────────────
if __name__ == '__main__':
plan = [
("title-dark", [
("Q1 2025 Business Review",
"Office Rotterdam — Maandoverzicht"),
("Kwartaalresultaten, strategische prioriteiten en roadmap H2",
"Highlights, events en community · maart–april 2026"),
("Team Strategy & Leadership", "Office Rotterdam"),
]),
("agenda", [
("Premium Hardcover Boekjes", "Wat gebeurde er deze maand"),
("Q1 Resultaten", "Strategy Days"),
("Revenue, margins, order volume en key highlights",
"Strategische afstemming op 14 & 15 april"),
("5 min", "10 min"),
("Financial Performance", "CEO Bezoek"),
("Deep dive in de cijfers per markt en categorie",
"17 CEO's bij Michael's AI-talk op 2 april"),
("Product & Platform", "HelloBoxing"),
("Nieuwe features, marketplace expansie en tech roadmap",
"Bokslessen terug — elke 2 weken op donderdag"),
("AI & Automation", "Community Moments"),
("Skill updates, voortgang en planning voor HY1",
"Eten, cultuur en verbinding in het kantoor"),
]),
("section-divider", [
("02", "01"),
("Financial Performance", "Office Highlights"),
("Omzet, marges en key metrics over het afgelopen kwartaal met een vooruitblik op de rest van het jaar.",
"De belangrijkste momenten van de afgelopen maand op een rij."),
]),
("process-timeline", [
("WORKFLOW", "MAART → APRIL 2026"),
("Van ontwerp tot levering", "Een maand vol events en verbinding"),
("Upload", "Ronald McDonald"),
("Upload je ontwerp of gebruik onze online editor.",
"Team kookte op 25 maart voor gezinnen bij het Ronald McDonald Huis."),
("Designcheck", "HelloBoxing"),
("Automatische controle op resolutie, afloop en kleuren.",
"Bokslessen hernomen — bi-weekly op donderdag, start 16 april."),
("Productie", "CEO Bezoek"),
("We matchen je order aan de beste supplier.",
"17 CEO's aanwezig op 2 april voor Michael's AI-talk in the living."),
("Levering", "Strategy Days"),
("Track & trace tot aan je deur. Gemiddeld 5 werkdagen.",
"Twee dagen strategische afstemming op 14 & 15 april."),
("Gemiddelde levertijd: ", "Keynote: "),
("5 werkdagen in NL/BE", "vrijdag 17 april om 16:30"),
("Express: 2 werkdagen →", "Met de laatste strategy updates →"),
]),
("big-number", [
("Q1 HIGHLIGHT", "CEO BEZOEK"),
("+42%", "17"),
("Year-over-year growth in new customer acquisition",
"CEO's aanwezig bij Michael's AI-talk in de Rotterdam living"),
("Driven by marketplace expansion into 3 new markets and a 28% improvement in organic search visibility across all locales.",
"Op 2 april verwelkomde het Rotterdam kantoor 17 CEO's voor een sessie over AI en de toekomst van ons vak."),
]),
("usp-4-cards", [
("WAAROM HELLOPRINT", "MENSEN & CULTUUR"),
("Vier redenen om voor ons te kiezen",
"Community moments deze maand"),
("Laagste prijs", "Armeense lunch", 1),
("garantie", "Tolma & Ghapama", 1),
("All-inclusive prijzen zonder verborgen kosten. Vind je het goedkoper? Wij matchen het.",
"Sona nodigde iedereen uit voor een Armeense lunch op 10 april.", 1),
("30%", "10 apr", 1),
("goedkoper dan traditioneel", "Armeens lunchen", 1),
("Laagste prijs", "Easter Cake", 2),
("garantie", "Carrot Cake", 2),
("All-inclusive prijzen zonder verborgen kosten. Vind je het goedkoper? Wij matchen het.",
"Saskia bakte een worteltaart voor Pasen — op beiden verdiepingen!", 2),
("30%", "2 apr", 2),
("goedkoper dan traditioneel", "Paastraktatie", 2),
("Laagste prijs", "Chinese Snacks", 3),
("garantie", "Sachima & Mahua", 3),
("All-inclusive prijzen zonder verborgen kosten. Vind je het goedkoper? Wij matchen het.",
"Yaqi bracht traditionele Chinese snacks mee van haar vakantie.", 3),
("30%", "16 apr", 3),
("goedkoper dan traditioneel", "Internationaal genieten", 3),
("Laagste prijs", "Kinderboek Launch", 4),
("garantie", "My New Parents", 4),
("All-inclusive prijzen zonder verborgen kosten. Vind je het goedkoper? Wij matchen het.",
"Dennis' vrouw lanceerde haar eerste kinderboek — met de hand geschreven en getekend!", 4),
("30%", "9 apr", 4),
("goedkoper dan traditioneel", "NL & EN editie", 4),
]),
("testimonial", [
("Helloprint heeft ons de flexibiliteit gegeven om snel te schakelen. Van 500 visitekaartjes tot 50.000 brochures — alles wordt op tijd en perfect geleverd.",
"Rolling dolma boosts productivity by 89%. No proof... but worth testing! Kom naar boven om mee te helpen met Tolma en Ghapama voor de Armeense lunch."),
("Martine Verhoeven", "Sona Hovhannisyan"),
("Marketing Director — TechCorp BV",
"People & Culture — Helloprint Rotterdam"),
("MV", "SH"),
]),
("section-divider", [
("02", "02"),
("Financial Performance", "Upcoming"),
("Omzet, marges en key metrics over het afgelopen kwartaal met een vooruitblik op de rest van het jaar.",
"Wat staat er op de planning voor de komende weken."),
]),
("closing", []),
]
print("Generating: Office Rotterdam — Maandoverzicht\n")
generate(plan, "/Users/derkdissselhof/Library/Application Support/Claude/local-agent-mode-sessions/a2d0567c-5a2d-4037-a100-87a5e74b9a55/3f471e31-d031-4ffe-bb3a-282799a05800/local_0cbc5cf3-74a0-4400-80d7-33c648a960dc/outputs/OfficeRotterdam_Maandoverzicht_Apr2026.pptx")