Add shared DOCX style module + campaign tools

_styles.py: Centralized typography, spacing, and formatting for all
26 DOCX generators. Calibri 9.5pt body, 1.15 line spacing, navy
headings, consistent signature blocks, page numbers, PW footer.
All generators will be migrated to use this instead of defining
their own styles.

Campaign tools:
- campaign_template.html: Styled email template for Listmonk campaigns
- populate_deficiency_list.py: Populates Listmonk with FCC deficiency data
- send_test_campaigns.py: Sends test emails with real carrier data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-05-04 08:52:07 -05:00
parent c9881868dd
commit 463c180444
4 changed files with 731 additions and 0 deletions

View file

@ -0,0 +1,307 @@
"""Shared document styles for all Performance West DOCX generators.
Import this module in every generator to ensure consistent typography,
spacing, and formatting across all compliance documents.
Usage:
from scripts.document_gen.templates._styles import (
PW, apply_doc_defaults, add_body, add_heading, add_bullets,
add_signature_block, add_page_numbers, add_horizontal_rule,
)
All generators should call apply_doc_defaults(doc) first, then use
the helper functions instead of building paragraphs manually.
"""
from __future__ import annotations
import logging
from typing import Optional
LOG = logging.getLogger("document_gen.styles")
try:
from docx import Document
from docx.shared import Pt, Inches, RGBColor, Emu
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
except ImportError:
LOG.warning("python-docx not installed")
Document = None
# ═══════════════════════════════════════════════════════════════════════════
# Color palette
# ═══════════════════════════════════════════════════════════════════════════
class PW:
"""Performance West brand colors and typography constants."""
NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None
DARK_GRAY = RGBColor(0x37, 0x41, 0x51) if Document else None
MEDIUM_GRAY = RGBColor(0x6B, 0x72, 0x80) if Document else None
LIGHT_GRAY = RGBColor(0x9C, 0xA3, 0xAF) if Document else None
BLACK = RGBColor(0x00, 0x00, 0x00) if Document else None
GREEN = RGBColor(0x05, 0x96, 0x69) if Document else None
RED = RGBColor(0xDC, 0x26, 0x26) if Document else None
# Typography
FONT_FAMILY = "Calibri"
BODY_SIZE = Pt(9.5) if Document else None
BODY_SMALL = Pt(8.5) if Document else None
HEADING_1_SIZE = Pt(13) if Document else None
HEADING_2_SIZE = Pt(11) if Document else None
HEADING_3_SIZE = Pt(10) if Document else None
TITLE_SIZE = Pt(15) if Document else None
SUBTITLE_SIZE = Pt(10) if Document else None
FOOTER_SIZE = Pt(7.5) if Document else None
SIGNATURE_SIZE = Pt(9.5) if Document else None
# Spacing
BODY_AFTER = Pt(8) if Document else None
HEADING_BEFORE = Pt(16) if Document else None
HEADING_AFTER = Pt(6) if Document else None
BULLET_AFTER = Pt(4) if Document else None
LINE_SPACING = 1.15
# Margins
MARGIN_TOP = Inches(0.9) if Document else None
MARGIN_BOTTOM = Inches(0.9) if Document else None
MARGIN_LEFT = Inches(1.1) if Document else None
MARGIN_RIGHT = Inches(1.1) if Document else None
# ═══════════════════════════════════════════════════════════════════════════
# Document setup
# ═══════════════════════════════════════════════════════════════════════════
def apply_doc_defaults(doc, title: str = "", entity_name: str = "") -> None:
"""Apply standard margins, default font, and optional header/footer."""
for section in doc.sections:
section.top_margin = PW.MARGIN_TOP
section.bottom_margin = PW.MARGIN_BOTTOM
section.left_margin = PW.MARGIN_LEFT
section.right_margin = PW.MARGIN_RIGHT
# Set default font on the document's style
style = doc.styles["Normal"]
style.font.name = PW.FONT_FAMILY
style.font.size = PW.BODY_SIZE
style.font.color.rgb = PW.DARK_GRAY
style.paragraph_format.space_after = PW.BODY_AFTER
style.paragraph_format.line_spacing = PW.LINE_SPACING
def add_page_numbers(doc) -> None:
"""Add centered page numbers to the document footer."""
for section in doc.sections:
footer = section.footer
footer.is_linked_to_previous = False
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Page X
run = p.add_run()
run.font.size = PW.FOOTER_SIZE
run.font.color.rgb = PW.LIGHT_GRAY
run.font.name = PW.FONT_FAMILY
fc_begin = OxmlElement("w:fldChar")
fc_begin.set(qn("w:fldCharType"), "begin")
run._element.append(fc_begin)
r2 = p.add_run()
r2.font.size = PW.FOOTER_SIZE
r2.font.color.rgb = PW.LIGHT_GRAY
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = " PAGE "
r2._element.append(instr)
r3 = p.add_run()
fc_end = OxmlElement("w:fldChar")
fc_end.set(qn("w:fldCharType"), "end")
r3._element.append(fc_end)
def add_pw_footer(doc, entity_name: str = "") -> None:
"""Add 'Prepared by Performance West Inc.' footer with page numbers."""
for section in doc.sections:
footer = section.footer
footer.is_linked_to_previous = False
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
# "Prepared by Performance West Inc." text
run = p.add_run("Prepared by Performance West Inc.")
run.font.size = PW.FOOTER_SIZE
run.font.color.rgb = PW.LIGHT_GRAY
run.font.name = PW.FONT_FAMILY
run.italic = True
# Add separator and page number
sep = p.add_run(" \u2014 Page ")
sep.font.size = PW.FOOTER_SIZE
sep.font.color.rgb = PW.LIGHT_GRAY
sep.font.name = PW.FONT_FAMILY
# Page number field
fc_begin = OxmlElement("w:fldChar")
fc_begin.set(qn("w:fldCharType"), "begin")
r_page = p.add_run()
r_page.font.size = PW.FOOTER_SIZE
r_page.font.color.rgb = PW.LIGHT_GRAY
r_page._element.append(fc_begin)
r_instr = p.add_run()
r_instr.font.size = PW.FOOTER_SIZE
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = " PAGE "
r_instr._element.append(instr)
r_end = p.add_run()
fc_end = OxmlElement("w:fldChar")
fc_end.set(qn("w:fldCharType"), "end")
r_end._element.append(fc_end)
# ═══════════════════════════════════════════════════════════════════════════
# Content helpers
# ═══════════════════════════════════════════════════════════════════════════
def add_title(doc, text: str, subtitle: str = "") -> None:
"""Add a document title (centered, navy, large)."""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_after = Pt(2)
run = p.add_run(text)
run.font.size = PW.TITLE_SIZE
run.bold = True
run.font.color.rgb = PW.NAVY
run.font.name = PW.FONT_FAMILY
if subtitle:
p2 = doc.add_paragraph()
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
p2.paragraph_format.space_after = Pt(12)
run2 = p2.add_run(subtitle)
run2.font.size = PW.SUBTITLE_SIZE
run2.italic = True
run2.font.color.rgb = PW.MEDIUM_GRAY
run2.font.name = PW.FONT_FAMILY
def add_heading(doc, text: str, level: int = 1) -> None:
"""Add a section heading (navy, bold)."""
p = doc.add_paragraph()
size = {1: PW.HEADING_1_SIZE, 2: PW.HEADING_2_SIZE, 3: PW.HEADING_3_SIZE}.get(level, PW.HEADING_2_SIZE)
before = PW.HEADING_BEFORE if level == 1 else Pt(10)
p.paragraph_format.space_before = before
p.paragraph_format.space_after = PW.HEADING_AFTER
run = p.add_run(text)
run.bold = True
run.font.size = size
run.font.color.rgb = PW.NAVY
run.font.name = PW.FONT_FAMILY
def add_body(doc, text: str, bold: bool = False, italic: bool = False,
size=None, color=None, alignment=None) -> None:
"""Add a body paragraph."""
p = doc.add_paragraph()
p.paragraph_format.space_after = PW.BODY_AFTER
p.paragraph_format.line_spacing = PW.LINE_SPACING
if alignment:
p.alignment = alignment
run = p.add_run(text)
run.font.size = size or PW.BODY_SIZE
run.font.name = PW.FONT_FAMILY
run.font.color.rgb = color or PW.DARK_GRAY
run.bold = bold
run.italic = italic
def add_field_value(doc, label: str, value: str) -> None:
"""Add a label: value pair on one line."""
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(3)
p.paragraph_format.line_spacing = PW.LINE_SPACING
run_label = p.add_run(f"{label}: ")
run_label.font.size = PW.BODY_SIZE
run_label.font.name = PW.FONT_FAMILY
run_label.font.color.rgb = PW.MEDIUM_GRAY
run_label.bold = False
run_value = p.add_run(value)
run_value.font.size = PW.BODY_SIZE
run_value.font.name = PW.FONT_FAMILY
run_value.font.color.rgb = PW.DARK_GRAY
run_value.bold = True
def add_bullets(doc, items: list[str], indent: float = 0.25) -> None:
"""Add a bulleted list."""
for item in items:
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Inches(indent)
p.paragraph_format.space_after = PW.BULLET_AFTER
p.paragraph_format.line_spacing = PW.LINE_SPACING
p.clear()
run = p.add_run(item)
run.font.size = PW.BODY_SIZE
run.font.name = PW.FONT_FAMILY
run.font.color.rgb = PW.DARK_GRAY
def add_horizontal_rule(doc) -> None:
"""Add a thin navy horizontal rule."""
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(6)
pPr = p._p.get_or_add_pPr()
pBdr = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), "4")
bottom.set(qn("w:space"), "1")
bottom.set(qn("w:color"), "1A2744")
pBdr.append(bottom)
pPr.append(pBdr)
def add_signature_block(
doc,
signer_name: str = "",
signer_title: str = "",
entity_name: str = "",
date_str: str = "",
include_perjury: bool = False,
) -> None:
"""Add a standardized signature block."""
if include_perjury:
add_body(doc, (
"I declare under penalty of perjury under the laws of the "
"United States of America that the foregoing is true and correct."
), italic=True, size=PW.BODY_SMALL)
# Signature line
add_body(doc, "", size=Pt(20)) # spacer
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(2)
run = p.add_run("_" * 45)
run.font.size = PW.SIGNATURE_SIZE
run.font.name = PW.FONT_FAMILY
run.font.color.rgb = PW.LIGHT_GRAY
if signer_name:
add_body(doc, signer_name, bold=True, size=PW.SIGNATURE_SIZE)
if signer_title:
add_body(doc, signer_title, size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY)
if entity_name:
add_body(doc, entity_name, size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY)
if date_str:
add_body(doc, f"Date: {date_str}", size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY)