Files
monica_site/proposal/powerpoint/build_cost_proposal.py
T
2026-07-16 05:48:12 -05:00

1009 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 23"),
("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"),
("~1014 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 24 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()