feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
Executable
+270
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyse a reference .pptx and synthesise a deck-wide design footprint.
|
||||
|
||||
Reads `ppt/theme/theme1.xml` (color scheme + font scheme) and the first
|
||||
`ppt/slides/slide1.xml` (actual rPr usage) from the pptx zip. Produces:
|
||||
|
||||
<output_dir>/global.css — slide-canvas CSS using extracted variables
|
||||
<output_dir>/design.json — palette + typography in slides_brief.json
|
||||
shape, plus the raw theme dump for audit
|
||||
|
||||
Usage:
|
||||
python extract_pptx_style.py <pptx_path> <output_dir>
|
||||
|
||||
The script imitates the pptx's palette / typography so downstream slide
|
||||
generation can stay faithful to the reference deck. No external deps —
|
||||
uses zipfile + xml.etree only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
NS = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
|
||||
# Standard PowerPoint preset colors → hex. Used when a clrScheme slot points at
|
||||
# a named preset rather than an explicit srgbClr.
|
||||
PRESET_COLORS = {
|
||||
"windowText": "#000000", "windowBackground": "#FFFFFF",
|
||||
"menuText": "#000000", "menuBar": "#FFFFFF",
|
||||
}
|
||||
|
||||
|
||||
def _hex_from_node(node: ET.Element | None) -> str | None:
|
||||
if node is None:
|
||||
return None
|
||||
srgb = node.find("a:srgbClr", NS)
|
||||
if srgb is not None and srgb.get("val"):
|
||||
return "#" + srgb.get("val").upper()
|
||||
sys_ = node.find("a:sysClr", NS)
|
||||
if sys_ is not None:
|
||||
# sysClr always carries a lastClr fallback for the rendered hex.
|
||||
last = sys_.get("lastClr")
|
||||
if last:
|
||||
return "#" + last.upper()
|
||||
val = sys_.get("val")
|
||||
if val and val in PRESET_COLORS:
|
||||
return PRESET_COLORS[val]
|
||||
preset = node.find("a:prstClr", NS)
|
||||
if preset is not None and preset.get("val") in PRESET_COLORS:
|
||||
return PRESET_COLORS[preset.get("val")]
|
||||
return None
|
||||
|
||||
|
||||
def parse_theme(theme_xml: bytes) -> dict:
|
||||
root = ET.fromstring(theme_xml)
|
||||
out: dict = {"palette": {}, "fonts": {"heading": None, "body": None}}
|
||||
|
||||
clr = root.find(".//a:clrScheme", NS)
|
||||
if clr is not None:
|
||||
for slot in (
|
||||
"dk1", "lt1", "dk2", "lt2",
|
||||
"accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
|
||||
"hlink", "folHlink",
|
||||
):
|
||||
node = clr.find(f"a:{slot}", NS)
|
||||
hex_val = _hex_from_node(node)
|
||||
if hex_val:
|
||||
out["palette"][slot] = hex_val
|
||||
|
||||
fonts = root.find(".//a:fontScheme", NS)
|
||||
if fonts is not None:
|
||||
major = fonts.find("a:majorFont/a:latin", NS)
|
||||
if major is not None and major.get("typeface"):
|
||||
out["fonts"]["heading"] = major.get("typeface")
|
||||
minor = fonts.find("a:minorFont/a:latin", NS)
|
||||
if minor is not None and minor.get("typeface"):
|
||||
out["fonts"]["body"] = minor.get("typeface")
|
||||
# East-asian fallback — useful when the deck is Chinese.
|
||||
major_ea = fonts.find("a:majorFont/a:ea", NS)
|
||||
if major_ea is not None and major_ea.get("typeface"):
|
||||
out["fonts"]["heading_ea"] = major_ea.get("typeface")
|
||||
minor_ea = fonts.find("a:minorFont/a:ea", NS)
|
||||
if minor_ea is not None and minor_ea.get("typeface"):
|
||||
out["fonts"]["body_ea"] = minor_ea.get("typeface")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def sample_slide_usage(slide_xml: bytes) -> dict:
|
||||
"""Inspect actual srgbClr fills + rPr typefaces used on slide 1."""
|
||||
root = ET.fromstring(slide_xml)
|
||||
colors = collections.Counter()
|
||||
typefaces = collections.Counter()
|
||||
sizes = collections.Counter()
|
||||
for srgb in root.iter(f"{{{NS['a']}}}srgbClr"):
|
||||
if srgb.get("val"):
|
||||
colors["#" + srgb.get("val").upper()] += 1
|
||||
for rpr in root.iter(f"{{{NS['a']}}}rPr"):
|
||||
sz = rpr.get("sz")
|
||||
if sz:
|
||||
sizes[int(sz)] += 1
|
||||
for tag in ("latin", "ea", "cs"):
|
||||
face = rpr.find(f"a:{tag}", NS)
|
||||
if face is not None and face.get("typeface"):
|
||||
typefaces[face.get("typeface")] += 1
|
||||
return {
|
||||
"top_colors": colors.most_common(8),
|
||||
"top_typefaces": typefaces.most_common(6),
|
||||
"top_font_sizes_hpt": sizes.most_common(6), # OOXML stores sz in hundredths of pt
|
||||
}
|
||||
|
||||
|
||||
def synthesize_design(theme: dict, slide: dict | None) -> dict:
|
||||
p = theme["palette"]
|
||||
bg = p.get("lt1") or p.get("lt2") or "#FFFFFF"
|
||||
primary = p.get("dk1") or p.get("dk2") or "#1C1917"
|
||||
accent = p.get("accent1") or "#2563EB"
|
||||
|
||||
# If slide1 uses a markedly more common non-bg, non-primary color, prefer
|
||||
# it as the accent — themes sometimes carry a placeholder accent that the
|
||||
# actual deck never uses.
|
||||
if slide:
|
||||
for hex_val, _count in slide["top_colors"]:
|
||||
if hex_val.upper() not in {bg.upper(), primary.upper()}:
|
||||
accent = hex_val
|
||||
break
|
||||
|
||||
heading = theme["fonts"].get("heading") or "Inter"
|
||||
body = theme["fonts"].get("body") or heading
|
||||
return {
|
||||
"background": bg,
|
||||
"primary": primary,
|
||||
"accent": accent,
|
||||
"heading_font": heading,
|
||||
"body_font": body,
|
||||
}
|
||||
|
||||
|
||||
CSS_TEMPLATE = """\
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Crimson+Text:wght@400;600;700&family=Noto+Sans+SC:wght@400;500;700&family=Noto+Serif+SC:wght@400;700&display=swap');
|
||||
|
||||
:root {{
|
||||
--bg: {bg};
|
||||
--primary: {primary};
|
||||
--accent: {accent};
|
||||
}}
|
||||
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
|
||||
body {{
|
||||
font-family: '{body_font}', 'Inter', 'Helvetica Neue', 'Segoe UI', Arial,
|
||||
'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--primary);
|
||||
}}
|
||||
|
||||
.slide {{
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
background-color: var(--bg);
|
||||
padding: 64px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
h1, h2, h3, .heading {{
|
||||
font-family: '{heading_font}', 'Crimson Text', 'Times New Roman', Georgia,
|
||||
'Noto Serif SC', 'Songti SC', 'SimSun', serif;
|
||||
color: var(--primary);
|
||||
line-height: 1.1;
|
||||
}}
|
||||
|
||||
h1 {{ font-size: 88px; }}
|
||||
h2 {{ font-size: 52px; }}
|
||||
h3 {{ font-size: 32px; }}
|
||||
|
||||
p, li, td, .body-text {{
|
||||
font-family: '{body_font}', 'Inter', 'Helvetica Neue', Arial,
|
||||
'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 22px;
|
||||
line-height: 1.55;
|
||||
color: var(--primary);
|
||||
}}
|
||||
|
||||
.accent-fg {{ color: var(--accent); }}
|
||||
.accent-bg {{ background-color: var(--accent); color: var(--bg); }}
|
||||
.accent-border {{ border: 4px solid var(--accent); }}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
ap.add_argument("pptx_path", type=Path)
|
||||
ap.add_argument("output_dir", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.pptx_path.is_file() or args.pptx_path.suffix.lower() != ".pptx":
|
||||
print(f"error: {args.pptx_path} is not a .pptx file", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(args.pptx_path) as z:
|
||||
names = z.namelist()
|
||||
theme_files = sorted(n for n in names if re.match(r"ppt/theme/theme\d+\.xml$", n))
|
||||
if not theme_files:
|
||||
print("error: no ppt/theme/theme*.xml inside pptx", file=sys.stderr)
|
||||
return 1
|
||||
theme_xml = z.read(theme_files[0])
|
||||
slide_xml = None
|
||||
if "ppt/slides/slide1.xml" in names:
|
||||
slide_xml = z.read("ppt/slides/slide1.xml")
|
||||
|
||||
theme = parse_theme(theme_xml)
|
||||
slide = sample_slide_usage(slide_xml) if slide_xml else None
|
||||
design = synthesize_design(theme, slide)
|
||||
|
||||
css = CSS_TEMPLATE.format(
|
||||
bg=design["background"],
|
||||
primary=design["primary"],
|
||||
accent=design["accent"],
|
||||
heading_font=design["heading_font"],
|
||||
body_font=design["body_font"],
|
||||
)
|
||||
css_path = args.output_dir / "global.css"
|
||||
css_path.write_text(css, encoding="utf-8")
|
||||
|
||||
design_payload = {
|
||||
"design": {
|
||||
"palette": {
|
||||
"background": design["background"],
|
||||
"primary": design["primary"],
|
||||
"accent": design["accent"],
|
||||
},
|
||||
"typography": {
|
||||
"heading": design["heading_font"],
|
||||
"body": design["body_font"],
|
||||
},
|
||||
"reference": f"imitating {args.pptx_path.name}",
|
||||
},
|
||||
"source_pptx": str(args.pptx_path.resolve()),
|
||||
"raw_theme": theme,
|
||||
"slide1_usage": slide,
|
||||
}
|
||||
design_path = args.output_dir / "design.json"
|
||||
design_path.write_text(json.dumps(design_payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
|
||||
print(f"wrote {css_path}")
|
||||
print(f"wrote {design_path}")
|
||||
print(f"palette: bg={design['background']} primary={design['primary']} accent={design['accent']}")
|
||||
print(f"fonts: heading={design['heading_font']} body={design['body_font']}")
|
||||
if slide:
|
||||
print(f"slide1 top colors: {slide['top_colors'][:3]}")
|
||||
print(f"slide1 top typefaces: {slide['top_typefaces'][:3]}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user