531 lines
25 KiB
Python
531 lines
25 KiB
Python
"""
|
||
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 &
|
||
|
||
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('&', '&').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'<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")
|