#!/usr/bin/env python3 """Build MKDRealtor.com cost proposal deck from WebsiteProposalTemplate.pptx. Colors match Exit Realty Wheaton / proposal.css. Two cost sections: 1) Build-out (one-time) 2) Monthly maintenance """ from __future__ import annotations import re import shutil from datetime import date from pathlib import Path from zipfile import ZIP_DEFLATED, ZipFile from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE, MSO_SHAPE_TYPE from pptx.enum.text import PP_ALIGN from pptx.oxml.ns import qn from pptx.util import Inches, Pt ROOT = Path(__file__).resolve().parent TEMPLATE = ROOT / "WebsiteProposalTemplate.pptx" OUTPUT = ROOT / "MonicaSite-CostProposal.pptx" SHOTS = ROOT / "screenshots" LOGO = ROOT / "aiml-operations-logo.png" # proposal.css palette MONICA = { "primary": "00626C", "primary_light": "008898", "primary_soft": "00A0AB", "navy": "212121", "ink": "212121", "sidebar": "080808", "muted": "6B7280", "surface": "F4F7F7", "border": "D9E3E4", "ok": "1A7F4B", "link": "7DD3DA", "white": "FFFFFF", } # Warm template accents → Monica blues COLOR_MAP = { "63876D": MONICA["primary"], "F1C988": MONICA["primary_light"], "FF8D51": MONICA["primary_soft"], "CB5A48": MONICA["navy"], "9F4044": MONICA["ink"], "632F2F": MONICA["sidebar"], "181717": MONICA["ink"], "954F72": MONICA["primary_light"], "0563C1": MONICA["primary"], } TODAY = date(2026, 7, 15) DATE_STR = TODAY.strftime("%d / %m / %Y") COMPANY = "AI/ML Operations" PROJECT = "MKDRealtor.com" # --- Pricing (proposal estimates; adjust as needed) --- BUILD_ITEMS = [ ("Phase 0 — Scaffolding & deploy", "$1,800"), ("Phase 1 — Public site, leads, UTM, portal", "$4,800"), ("Phase 2 — Mailing list + email/SMS outreach", "$3,600"), ("Phase 3 — Postcards + live designer", "$2,400"), ("Phase 4 — Social automation (FB / IG / LI)", "$3,200"), ("Phase 5 — Hardening, monitoring, handoff", "$1,200"), ("Content polish & realtor training", "$1,000"), ] BUILD_TOTAL = "$18,000" MONTHLY_PLANS = { "headers": ["Essential", "Standard", "Recommended", "Full Care"], "rows": [ ["Hosting & monitoring", "Yes", "Yes", "Yes", "Yes"], ["Security & dependency updates", "Yes", "Yes", "Yes", "Yes"], ["Bug fixes & uptime support", "Email", "Priority", "Priority", "Priority"], ["Minor content / copy tweaks", "—", "2 hrs/mo", "4 hrs/mo", "8 hrs/mo"], ["Campaign & social help", "—", "—", "Yes", "Yes"], ["Monthly health report", "—", "Yes", "Yes", "Yes"], ["$149/mo", "$249/mo", "$349/mo", "$499/mo"], ], } MONTHLY_PASS_THROUGH = [ ("Email / SMS (SMTP2GO)", "Usage-based — billed at provider cost"), ("Postcards (Lob / Click2Mail)", "~$0.35–$0.77 per 4×6 piece"), ("Social APIs (Meta + LinkedIn)", "$0 — native APIs, no aggregator fee"), ("Infra (existing fleet)", "Near-zero marginal — shared Postgres only"), ] # Screenshots from proposal/pages (see capture_screenshots.py) UX_PAGES = [ ("index", "UX gallery hub", "Overview"), ("public-home", "Marketing home", "Public · Phase 1"), ("public-about", "About", "Public · Phase 1"), ("public-contact", "Contact form", "Public · Phase 1"), ("public-unsubscribe", "Opt-out / unsubscribe", "Public · Phase 2"), ("public-404", "404 not found", "Public · system"), ("public-under-construction", "Under construction", "Public · system"), ("portal-login", "Portal login", "Portal · Phase 1"), ("portal-dashboard", "Dashboard", "Portal · Phase 1"), ("portal-leads", "Lead inbox", "Portal · Phase 1"), ("portal-lead-detail", "Lead detail", "Portal · Phase 1"), ("portal-analytics", "UTM analytics", "Portal · Phase 1"), ("portal-contacts", "Mailing list", "Portal · Phase 2"), ("portal-contacts-import", "Import contacts", "Portal · Phase 2"), ("portal-campaign", "Campaign composer", "Portal · Phase 2–3"), ("portal-campaign-report", "Email engagement", "Portal · Phase 2"), ("portal-postcard-designer", "Postcard designer", "Portal · Phase 3"), ("portal-social", "Social live preview", "Portal · Phase 4"), ("portal-social-accounts", "Social accounts", "Portal · Phase 4"), ] def _rgb(hex6: str) -> RGBColor: return RGBColor.from_string(hex6) def _logo_size(height_emu: int) -> tuple[int, int]: from PIL import Image with Image.open(LOGO) as im: aspect = im.width / im.height return int(height_emu * aspect), int(height_emu) def add_brand_logo(slide, slide_width: int, slide_height: int, *, height: int | None = None) -> None: """Place AI/ML Operations logo bottom-right.""" if not LOGO.exists(): print(f"WARN missing logo: {LOGO}") return logo_h = height or Inches(0.32) logo_w, logo_h = _logo_size(logo_h) left = slide_width - Inches(0.4) - logo_w top = slide_height - Inches(0.48) slide.shapes.add_picture(str(LOGO), left, top, width=logo_w, height=logo_h) def _set_run(paragraph, text: str, *, size_pt: float, bold: bool = False, color: str = "FFFFFF") -> None: paragraph.clear() run = paragraph.add_run() run.text = text run.font.size = Pt(size_pt) run.font.bold = bold run.font.color.rgb = _rgb(color) run.font.name = "Calibri" def add_ux_gallery(prs: Presentation) -> int: """Append UX section divider + one slide per screenshot. Returns slide count added.""" layout = prs.slide_layouts[0] # Blank added = 0 sw, sh = prs.slide_width, prs.slide_height # --- Section divider --- slide = prs.slides.add_slide(layout) bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, sw, sh) bg.fill.solid() bg.fill.fore_color.rgb = _rgb(MONICA["navy"]) bg.line.fill.background() accent = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, 0, Inches(2.6), sw, Inches(0.08) ) accent.fill.solid() accent.fill.fore_color.rgb = _rgb(MONICA["primary_light"]) accent.line.fill.background() title_box = slide.shapes.add_textbox(Inches(0.8), Inches(2.85), Inches(11.5), Inches(1.0)) tf = title_box.text_frame tf.word_wrap = True _set_run(tf.paragraphs[0], "UX MOCKUPS", size_pt=44, bold=True, color="FFFFFF") sub = slide.shapes.add_textbox(Inches(0.8), Inches(3.9), Inches(11.5), Inches(1.2)) stf = sub.text_frame stf.word_wrap = True _set_run( stf.paragraphs[0], "Click-through mockups of the public site and realtor portal — " "what the build-out delivers. Screenshots from proposal/pages (MKDRealtor.com).", size_pt=16, color="7DD3DA", ) foot = slide.shapes.add_textbox(Inches(0.8), Inches(6.9), Inches(4), Inches(0.3)) _set_run(foot.text_frame.paragraphs[0], DATE_STR, size_pt=11, color="6B7280") add_brand_logo(slide, sw, sh, height=Inches(0.34)) added += 1 # --- One slide per page --- missing = [] for stem, title, eyebrow in UX_PAGES: path = SHOTS / f"{stem}.png" if not path.exists(): missing.append(stem) continue slide = prs.slides.add_slide(layout) # Surface background bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, sw, sh) bg.fill.solid() bg.fill.fore_color.rgb = _rgb(MONICA["surface"]) bg.line.fill.background() # Top bar bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, sw, Inches(0.72)) bar.fill.solid() bar.fill.fore_color.rgb = _rgb(MONICA["sidebar"]) bar.line.fill.background() eye = slide.shapes.add_textbox(Inches(0.4), Inches(0.08), Inches(8), Inches(0.28)) _set_run(eye.text_frame.paragraphs[0], eyebrow.upper(), size_pt=11, bold=True, color="7DD3DA") ttl = slide.shapes.add_textbox(Inches(0.4), Inches(0.30), Inches(10), Inches(0.38)) _set_run(ttl.text_frame.paragraphs[0], title, size_pt=20, bold=True, color="FFFFFF") tag = slide.shapes.add_textbox(Inches(10.2), Inches(0.22), Inches(2.8), Inches(0.35)) p = tag.text_frame.paragraphs[0] p.alignment = PP_ALIGN.RIGHT _set_run(p, "UX mockup", size_pt=11, bold=True, color="7DD3DA") # Fit 1440×900 screenshot into remaining area top = Inches(0.85) bottom_margin = Inches(0.42) side = Inches(0.35) max_w = sw - side * 2 max_h = sh - top - bottom_margin img_ratio = 1440 / 900 box_ratio = max_w / max_h if box_ratio > img_ratio: pic_h = max_h pic_w = int(pic_h * img_ratio) else: pic_w = max_w pic_h = int(pic_w / img_ratio) left = int((sw - pic_w) / 2) slide.shapes.add_picture(str(path), left, top, width=pic_w, height=pic_h) # Footer: page ref left, logo bottom-right fbox = slide.shapes.add_textbox(Inches(0.4), sh - Inches(0.32), Inches(6), Inches(0.28)) _set_run( fbox.text_frame.paragraphs[0], f"{PROJECT} · {stem}.html · {DATE_STR}", size_pt=10, color=MONICA["muted"], ) add_brand_logo(slide, sw, sh, height=Inches(0.28)) added += 1 if missing: print(f"WARN missing screenshots: {', '.join(missing)}") print(f" Run: .venv/bin/python proposal/powerpoint/capture_screenshots.py") return added def set_runs_text(shape, text: str) -> bool: """Replace all paragraph/run text in a shape while keeping first run formatting.""" if not shape.has_text_frame: return False tf = shape.text_frame # Flatten into first paragraph's first run when possible first_para = tf.paragraphs[0] if first_para.runs: first_para.runs[0].text = text for run in first_para.runs[1:]: run.text = "" for para in tf.paragraphs[1:]: for run in para.runs: run.text = "" return True # No runs — set paragraph text first_para.text = text return True def replace_text_everywhere(shape, replacements: dict[str, str]) -> None: """Substring replace across all runs (preserves split-run titles when exact).""" if shape.shape_type == MSO_SHAPE_TYPE.GROUP: for child in shape.shapes: replace_text_everywhere(child, replacements) return if not shape.has_text_frame: return for para in shape.text_frame.paragraphs: for run in para.runs: for old, new in replacements.items(): if old in run.text: run.text = run.text.replace(old, new) def set_shape_text_by_name(slide, name: str, text: str) -> bool: for shape in slide.shapes: if shape.name == name: return set_runs_text(shape, text) return False def iter_all_shapes(shapes): for shape in shapes: yield shape if shape.shape_type == MSO_SHAPE_TYPE.GROUP: yield from iter_all_shapes(shape.shapes) def find_shapes_with_text(slide, contains: str): hits = [] for shape in iter_all_shapes(slide.shapes): if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if contains in full: hits.append(shape) return hits def set_full_text(shape, text: str) -> None: """Set shape text, preferring single-run update for titles split across runs.""" if not shape.has_text_frame: return tf = shape.text_frame # If multiple runs in first para (e.g. "PRICES " + "TABLE"), put all in first run para = tf.paragraphs[0] if para.runs: para.runs[0].text = text for r in para.runs[1:]: r.text = "" for p in tf.paragraphs[1:]: for r in p.runs: r.text = "" else: para.text = text def fill_table(table, rows: list[list[str]]) -> None: for r_i, row_data in enumerate(rows): if r_i >= len(table.rows): break for c_i, value in enumerate(row_data): if c_i >= len(table.columns): break cell = table.cell(r_i, c_i) # Keep formatting: set via text_frame tf = cell.text_frame if tf.paragraphs and tf.paragraphs[0].runs: tf.paragraphs[0].runs[0].text = value for r in tf.paragraphs[0].runs[1:]: r.text = "" for p in tf.paragraphs[1:]: for r in p.runs: r.text = "" else: cell.text = value def delete_slide(prs: Presentation, index: int) -> None: sld_id_lst = prs.slides._sldIdLst # noqa: SLF001 sld_id = sld_id_lst[index] rId = sld_id.get(qn("r:id")) prs.part.drop_rel(rId) sld_id_lst.remove(sld_id) def recolor_pptx(path: Path) -> None: """Rewrite theme + hardcoded srgb colors inside the pptx zip.""" tmp = path.with_suffix(".recolor.tmp.pptx") with ZipFile(path, "r") as zin, ZipFile(tmp, "w", compression=ZIP_DEFLATED) as zout: for info in zin.infolist(): data = zin.read(info.filename) name = info.filename if name.endswith((".xml", ".rels")): text = data.decode("utf-8") # Theme scheme if "theme/theme1.xml" in name: text = text.replace( 'clrScheme name="Website Proposal 01"', 'clrScheme name="MKDRealtor"', ) for old, new in { "63876D": MONICA["primary"], "F1C988": MONICA["primary_light"], "FF8D51": MONICA["primary_soft"], "CB5A48": MONICA["navy"], "9F4044": MONICA["ink"], "632F2F": MONICA["sidebar"], "181717": MONICA["ink"], "954F72": MONICA["primary_light"], "0563C1": MONICA["primary"], }.items(): text = text.replace(f'srgbClr val="{old}"', f'srgbClr val="{new}"') text = text.replace(f'srgbClr val="{old.lower()}"', f'srgbClr val="{new}"') if "theme/theme2.xml" in name: for old, new in { "4472C4": MONICA["primary"], "ED7D31": MONICA["primary_light"], "A5A5A5": MONICA["muted"], "FFC000": MONICA["primary_soft"], "5B9BD5": MONICA["primary_light"], "70AD47": MONICA["ok"], "44546A": MONICA["ink"], "E7E6E6": MONICA["surface"], "0563C1": MONICA["primary"], "954F72": MONICA["primary_light"], }.items(): text = text.replace(f'srgbClr val="{old}"', f'srgbClr val="{new}"') # Global warm → Monica on slides / masters / layouts / charts for old, new in COLOR_MAP.items(): text = re.sub( rf'(?i)srgbClr val="{old}"', f'srgbClr val="{new}"', text, ) # Some fills use scheme-independent hex in other attrs text = re.sub( rf'(?i)val="{old}"', f'val="{new}"', text, ) data = text.encode("utf-8") zout.writestr(info, data) tmp.replace(path) def stamp_footer(slide, slide_width: int | None = None, slide_height: int | None = None) -> None: """Date bottom-left; clear bottom-right text so logo can sit there.""" left_footer = None right_footer = None for shape in slide.shapes: if not shape.has_text_frame: continue if shape.top < Inches(6.4): continue if shape.left < Inches(5): left_footer = shape elif shape.left > Inches(8): right_footer = shape if left_footer is not None: set_full_text(left_footer, DATE_STR) if right_footer is not None: set_full_text(right_footer, "") # Also catch leftover company name anywhere in footer band for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ).strip() if full in ("Company Name", COMPANY): set_full_text(shape, DATE_STR if shape.left < Inches(5) else "") elif "20 / 10 / 2020" in full: set_full_text(shape, DATE_STR if shape.left < Inches(5) else "") if slide_width is not None and slide_height is not None: add_brand_logo(slide, slide_width, slide_height) def fill_title(slide) -> None: stamp_footer(slide) for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "WEBSITE" in full or "PROPOSAL" in full: # Title is often two runs/paragraphs: WEBSITE / PROPOSAL paras = shape.text_frame.paragraphs if len(paras) >= 2 and paras[0].runs and paras[1].runs: paras[0].runs[0].text = "COST" for r in paras[0].runs[1:]: r.text = "" paras[1].runs[0].text = "PROPOSAL" for r in paras[1].runs[1:]: r.text = "" else: set_full_text(shape, "COST PROPOSAL") elif "Lorem Ipsum" in full or "dummy text" in full: set_full_text( shape, "Custom marketing platform for Monica Dhillon — public site, " "realtor portal, multi-channel outreach, and social automation. " "Cheaper than PostcardMania, with social included.", ) def fill_welcome(slide) -> None: stamp_footer(slide) for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "Opportunities" in full: set_full_text(shape, "Replace PostcardMania. Own your pipeline.") elif "WELCOME" in full or "MESSAGE" in full: set_full_text(shape, "WELCOME") elif "Lorem ipsum" in full.lower() or "consectetuer" in full.lower(): set_full_text( shape, "This proposal covers the one-time build-out of MKDRealtor.com and " "the ongoing monthly care plan. You get a branded public site, a " "private portal for leads and UTM analytics, email + SMS + postcard " "campaigns with consent/opt-out, and Facebook / Instagram / LinkedIn " "scheduling — deployed on our existing fleet with near-zero extra infra.", ) def fill_exec_summary(slide) -> None: stamp_footer(slide) for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "successful design projects" in full: set_full_text( shape, "Two cost sections: build-out (one-time) and monthly maintenance. " "Provider send costs (email, SMS, postcard) stay pass-through.", ) items = [ ("MKDRealtor.com platform", "Project name — realtor marketing + portal"), ("Single-tenant Django 6 app", "Deployed active/active behind NPM"), ("AI/ML Operations builds & hosts", "Same pipeline as company_site"), ("Django, Postgres, SMTP2GO, Lob, Meta, LinkedIn", "Stack & integrations"), ("LinkedIn app review + A2P 10DLC for SMS", "Client prerequisites"), ("Monica Dhillon / realtor brand", "Customer"), ("~10–14 weeks across 6 phases", "Timeline"), (f"{BUILD_TOTAL} build + from $149/mo care", "Investment summary"), ] # Groups are nested: outer Group 111 → Group 95..102 numbered = [] for shape in iter_all_shapes(slide.shapes): if shape.has_text_frame: t = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ).strip() if re.fullmatch(r"0[1-8]", t): numbered.append(shape) # Find description pairs near each number by walking groups at top level content_groups = [] for shape in slide.shapes: if shape.shape_type == MSO_SHAPE_TYPE.GROUP and shape.name.startswith("Group"): # look one level deeper for the 8 item groups for child in shape.shapes: if child.shape_type == MSO_SHAPE_TYPE.GROUP: content_groups.append(child) if len(content_groups) < 8: # fallback: any group that contains a 01-08 rectangle content_groups = [] for shape in iter_all_shapes(slide.shapes): if shape.shape_type != MSO_SHAPE_TYPE.GROUP: continue texts = [] for ch in shape.shapes: if ch.has_text_frame: texts.append( "".join( "".join(r.text for r in p.runs) for p in ch.text_frame.paragraphs ).strip() ) if any(re.fullmatch(r"0[1-8]", t) for t in texts): content_groups.append(shape) for grp, (desc, label) in zip(content_groups[:8], items): textboxes = [s for s in grp.shapes if s.has_text_frame] # Typically: detail textbox, label textbox, number rectangle detail = None title = None for s in textboxes: t = "".join( "".join(r.text for r in p.runs) for p in s.text_frame.paragraphs ).strip() if re.fullmatch(r"0[1-8]", t): continue if t.startswith("-") or "Lorem" in t or len(t) > 20 or t.startswith("PHP") or "$" in t or "Project" in t or "Risk" in t or "Investor" in t or "Your" in t or "brief" in t: if detail is None: detail = s elif title is None: title = s else: if title is None: title = s elif detail is None: detail = s # Heuristic by name order named = {s.name: s for s in textboxes} # Prefer: first non-number textbox = detail (- ...), second = label non_num = [] for s in textboxes: t = "".join( "".join(r.text for r in p.runs) for p in s.text_frame.paragraphs ).strip() if not re.fullmatch(r"0[1-8]", t): non_num.append(s) if len(non_num) >= 2: set_full_text(non_num[0], f"- {desc}") set_full_text(non_num[1], label) elif len(non_num) == 1: set_full_text(non_num[0], f"- {desc}") def fill_what_we_do(slide) -> None: stamp_footer(slide) for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "successful design projects" in full: set_full_text( shape, "Everything in the UX proposal gallery — shipped as a real Django app.", ) services = [ ("Public site", "Home, about, contact with reCAPTCHA v3 lead capture and consent."), ("Lead portal", "Inbox, statuses, notes, and UTM attribution for every inquiry."), ("Mailing list", "Contacts with per-channel consent, import, and easy opt-out."), ("Campaigns", "Email + SMS via SMTP2GO; postcards via Lob with live 4×6 designer."), ("Social", "Schedule once to Facebook, Instagram, and LinkedIn — native APIs."), ] groups = [ s for s in slide.shapes if s.shape_type == MSO_SHAPE_TYPE.GROUP and s.name.startswith("Group") ] for grp, (title, body) in zip(groups[:5], services): for s in grp.shapes: if not s.has_text_frame: continue t = "".join( "".join(r.text for r in p.runs) for p in s.text_frame.paragraphs ).strip() if t.startswith("Service"): set_full_text(s, title) elif "sample text" in t.lower() or "This is a" in t: set_full_text(s, body) def fill_planning(slide) -> None: stamp_footer(slide) for shape in iter_all_shapes(slide.shapes): if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "successful design projects" in full: set_full_text( shape, "Six delivery phases from scaffold to polish. Social can start in parallel with outreach.", ) elif full.strip() == "Define Target Audience": set_full_text(shape, "Scaffold & deploy") elif full.strip() == "Create a Sitemap": set_full_text(shape, "Public + leads + UTM") elif full.strip() == "SEO and Advertising": set_full_text(shape, "Mailing list + email/SMS") elif full.strip() == "Design and Developing": set_full_text(shape, "Postcards + social") elif full.strip() == "Set Goals and Objectives": set_full_text(shape, "Harden & handoff") elif "Lorem Ipsum" in full: # Match by parent context — replace with phase blurbs in order of groups pass blurbs = [ "Docker, CI, Postgres, task worker on the existing fleet.", "Marketing pages, portal login, lead inbox, analytics.", "Consent, campaigns, SMTP2GO sends, unsubscribe / STOP.", "Lob postcards + Meta/LinkedIn scheduling.", "Monitoring, backups, runbook, realtor training.", ] blurb_shapes = [] for shape in iter_all_shapes(slide.shapes): if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "Lorem Ipsum" in full and "dummy text of the printing" in full: blurb_shapes.append(shape) for shape, blurb in zip(blurb_shapes[:5], blurbs): set_full_text(shape, blurb) def fill_build_expenses(slide) -> None: stamp_footer(slide) for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if full.strip() == "EXPENSES": set_full_text(shape, "BUILD-OUT") elif "successful design projects" in full: set_full_text( shape, "Section 1 — One-time investment to design, build, and launch MKDRealtor.com.", ) elif full.strip() == "Expenses Title Here": set_full_text(shape, "Build-out line items") elif full.strip() == "Required Costs": set_full_text(shape, "Investment") for shape in slide.shapes: if shape.has_table: rows = [["Phase / deliverable", "Cost"]] for name, cost in BUILD_ITEMS: rows.append([name, cost]) # Table is 9 rows: header + 7 items + total. We have 7 items — perfect. rows.append([f"Total build-out", BUILD_TOTAL]) fill_table(shape.table, rows) break # Side callout groups often have "Short Text Here" / Lorem / Read More callouts = [ ( "Why this total", "Covers all six phases in the design doc — public site through social — plus training.", ), ( "Not included", "Postcard postage, SMS/email usage, and A2P registration are billed at provider cost.", ), ] # Find text boxes with Short Text / Lorem / Read More short_titles = find_shapes_with_text(slide, "Short Text") lorems = [ s for s in iter_all_shapes(slide.shapes) if s.has_text_frame and "Lorem Ipsum" in "".join( "".join(r.text for r in p.runs) for p in s.text_frame.paragraphs ) ] reads = find_shapes_with_text(slide, "Read More") for i, (title, body) in enumerate(callouts): if i < len(short_titles): set_full_text(short_titles[i], title) if i < len(lorems): set_full_text(lorems[i], body) if i < len(reads): set_full_text(reads[i], "See phases →") def fill_monthly_prices(slide) -> None: stamp_footer(slide) for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "PRICES" in full or "TABLE" in full: set_full_text(shape, "MONTHLY CARE") elif "successful design projects" in full: set_full_text( shape, "Section 2 — Ongoing maintenance so the platform stays secure and useful.", ) elif "Project Budget" in full: set_full_text(shape, "Care plans") elif "All" in full and "Budget" in full: set_full_text(shape, "Pick a plan") for shape in slide.shapes: if shape.has_table: headers = MONTHLY_PLANS["headers"] # Table is 7x5; use 4 plan columns — leave 5th as duplicate of Recommended or blank label # Actually 5 columns: map Essential, Standard, Recommended, Full, and a "Pass-through" note column # Simpler: use 4 plans in first 4 cols; 5th col = "Notes" style - use Full Care only 4 cols # Template has 5 columns — use: Essential | Standard | Recommended | Full Care | Pass-through # Template table = 7×5 with plan names as column headers (no label column) rows_data = [ ["Essential", "Standard", "Recommended", "Full Care", "Pass-through"], [ "Hosting included", "Hosting included", "Hosting included", "Hosting included", "Infra near-zero", ], [ "Security updates", "Security updates", "Security updates", "Security updates", "—", ], [ "Email support", "Priority support", "Priority support", "Priority + Slack", "—", ], [ "Platform only", "2 hrs content/mo", "4 hrs content/mo", "8 hrs content/mo", "—", ], [ "No campaign help", "No campaign help", "Campaign coaching", "Campaign + social", "Email/SMS/postcard", ], ["$149/mo", "$249/mo", "$349/mo", "$499/mo", "At cost"], ] # Table is 7 rows x 5 cols — rows_data matches fill_table(shape.table, rows_data) break def fill_client_responsibilities(slide) -> None: stamp_footer(slide) for shape in slide.shapes: if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "successful design projects" in full: set_full_text( shape, "A few client-side items unlock SMS, postcards, and LinkedIn posting.", ) items = [ ("Brand assets", "Logo, photos, bio copy, and preferred colors for the public site."), ("Domain & DNS", "Point the realtor domain to our NPM load balancer when ready."), ("SMTP2GO / SMS", "Approve SMTP2GO use; complete A2P 10DLC before SMS launch."), ("Postcard account", "Lob (or Click2Mail) API keys; artwork preferences for mailers."), ("Social accounts", "FB Page, IG Business, LinkedIn — OAuth connect in the portal."), ("LinkedIn review", "Allow 2–4 weeks for LinkedIn app posting-scope approval."), ] groups = [ s for s in slide.shapes if s.shape_type == MSO_SHAPE_TYPE.GROUP and s.name.startswith("Group") ] # Prefer the numbered item groups (have Oval with 01 etc.) item_groups = [] for g in groups: texts = [] for ch in iter_all_shapes(g.shapes): if ch.has_text_frame: texts.append( "".join( "".join(r.text for r in p.runs) for p in ch.text_frame.paragraphs ).strip() ) if any(re.fullmatch(r"0[1-6]", t) for t in texts): item_groups.append(g) for grp, (title, body) in zip(item_groups[:6], items): for s in iter_all_shapes(grp.shapes): if not s.has_text_frame: continue t = "".join( "".join(r.text for r in p.runs) for p in s.text_frame.paragraphs ).strip() if re.fullmatch(r"0[1-6]", t): continue if t == "Your Title Here" or "Title Here" in t: set_full_text(s, title) elif "sample text" in t.lower() or "This is a" in t: set_full_text(s, body) def fill_contact(slide) -> None: stamp_footer(slide) replacements = { "35 W Wacker Dr,": "AI/ML Operations", "606011 Chicago, US": "Hosted on adama + roslin", "contact@pp.com": "hello@aimloperations.com", "contact@wp.com": "portal support via ticket", "support@wp.com": "deploy via server-infra CI", "+1 312-220-0088": "Schedule a kickoff call", "1800 - 2800": "Build-out: " + BUILD_TOTAL, "1800 - 3318": "Care: from $149/mo", } for shape in iter_all_shapes(slide.shapes): if not shape.has_text_frame: continue full = "".join( "".join(r.text for r in p.runs) for p in shape.text_frame.paragraphs ) if "successful design projects" in full: set_full_text( shape, "Ready when you are — we start with Phase 0 scaffold and a staging URL.", ) continue for old, new in replacements.items(): if old in full: # replace within runs for para in shape.text_frame.paragraphs: for run in para.runs: if old in run.text: run.text = run.text.replace(old, new) def add_pass_through_note_slide(prs: Presentation, after_index: int) -> None: """Duplicate monthly slide is hard; instead annotate via exec — skip extra slide. Pass-through details live in build callouts + monthly *Usage column. """ return def main() -> None: if OUTPUT.exists(): OUTPUT.unlink() shutil.copy2(TEMPLATE, OUTPUT) # Recolor before python-pptx edits (zip rewrite) recolor_pptx(OUTPUT) prs = Presentation(str(OUTPUT)) # Keep only: title, welcome, exec, planning, prices, expenses, client, what-we-do, contact keep = {0, 1, 2, 4, 34, 35, 36, 38, 39} for idx in range(len(prs.slides) - 1, -1, -1): if idx not in keep: delete_slide(prs, idx) # current: [title, welcome, exec, planning, prices, expenses, client, whatwedo, contact] # target core: [title, welcome, exec, whatwedo, planning, expenses, prices, client, contact] sldIdLst = prs.slides._sldIdLst # noqa: SLF001 ids = list(sldIdLst) order = [0, 1, 2, 7, 3, 5, 4, 6, 8] new_ids = [ids[i] for i in order] for child in list(sldIdLst): sldIdLst.remove(child) for child in new_ids: sldIdLst.append(child) slides = list(prs.slides) fill_title(slides[0]) fill_welcome(slides[1]) fill_exec_summary(slides[2]) fill_what_we_do(slides[3]) fill_planning(slides[4]) fill_build_expenses(slides[5]) fill_monthly_prices(slides[6]) fill_client_responsibilities(slides[7]) fill_contact(slides[8]) # UX gallery appended, then moved after "What we do" n_before = len(list(prs.slides)) # 9 n_ux = add_ux_gallery(prs) sldIdLst = prs.slides._sldIdLst # noqa: SLF001 ids = list(sldIdLst) head = ids[:4] # title … what we do mid = ids[4:n_before] # planning … contact ux = ids[n_before : n_before + n_ux] for child in list(sldIdLst): sldIdLst.remove(child) for child in head + ux + mid: sldIdLst.append(child) # Final footer + logo on template slides (UX slides already branded) sw, sh = prs.slide_width, prs.slide_height for slide in list(prs.slides)[:4] + list(prs.slides)[4 + n_ux :]: stamp_footer(slide, sw, sh) for shape in iter_all_shapes(slide.shapes): if not shape.has_text_frame: continue for para in shape.text_frame.paragraphs: for run in para.runs: if run.text.strip() in ("Company Name", COMPANY): run.text = "" if "20 / 10 / 2020" in run.text: run.text = run.text.replace("20 / 10 / 2020", DATE_STR) prs.save(str(OUTPUT)) print(f"Wrote {OUTPUT}") print(f"Slides: {len(list(prs.slides))} (incl. {n_ux} UX mockup slides)") print(f"Logo: {LOGO.name}") print(f"Build-out total: {BUILD_TOTAL}") print("Monthly: $149 / $249 / $349 / $499") if __name__ == "__main__": main()