Templates (22 files): - Replace "Reviewed By" with "Document prepared by" + consulting disclaimer - Add "not a law firm / not legal advice" footer to all CPNI, CALEA, RMD docs - Change "on behalf of" to "at the direction of" in discontinuance letter - Reframe RMD penalty language as client acknowledgment Bounce sync: - New listmonk-bounce-sync.py replaces unreliable bash tail watcher - Scans full mail.log, matches QIDs to campaign senders, inserts directly into Listmonk DB with proper subscriber_id foreign keys - Idempotent, runs via cron every 5 minutes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""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., a regulatory compliance consulting firm")
|
|
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)
|