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:
parent
c9881868dd
commit
463c180444
4 changed files with 731 additions and 0 deletions
307
scripts/document_gen/templates/_styles.py
Normal file
307
scripts/document_gen/templates/_styles.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue