From cfbfda280661627fa2f976269c15fa4b872d731e Mon Sep 17 00:00:00 2001 From: "derk.disselhoff" Date: Thu, 16 Apr 2026 11:12:31 +0000 Subject: [PATCH] =?UTF-8?q?Add=20helloprint-presentation-creator=20skill?= =?UTF-8?q?=20=E2=80=94=20generate=5Fpresentation.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generate_presentation.py | 530 ++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 helloprint-presentation-creator/generate_presentation.py diff --git a/helloprint-presentation-creator/generate_presentation.py b/helloprint-presentation-creator/generate_presentation.py new file mode 100644 index 0000000..0426467 --- /dev/null +++ b/helloprint-presentation-creator/generate_presentation.py @@ -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']*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")