""" 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']*r:id="([^"]+)"', prs_xml) rid_to_target = {} for m in re.finditer( r' 2 else None old_esc = old.replace('&', '&').replace('<', '<').replace('>', '>') new_esc = new.replace('&', '&').replace('<', '<').replace('>', '>') 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'', 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'' ) 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'()(.*?)()', 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']*/>', keep_rel, data) copy_rels = ''.join( f'' for copy_rid, (_, _, _, copy_num) in copies.items() ) data = data.replace('', copy_rels + '') 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']*/>', keep_ct, data) copy_cts = ''.join( f'' for _, (_, _, _, copy_num) in copies.items() ) data = data.replace('', copy_cts + '') 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")