1009 lines
38 KiB
Python
1009 lines
38 KiB
Python
#!/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()
|