new-site/scripts/workers/binder_compiler.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

466 lines
16 KiB
Python

"""
Corporate binder compiler.
Takes a list of individual PDF documents and compiles them into a single
corporate binder with:
- Cover page (entity name, BC number, date, PW branding)
- Table of contents
- Colored tab divider pages between sections (navy blue background, white text)
- All PDFs merged in order
- Page numbers on every page
Uses pikepdf for PDF manipulation and reportlab for generated pages
(cover, TOC, dividers, page numbers).
Usage:
compiler = BinderCompiler()
binder_path = compiler.compile(
entity_name="My Corp Ltd.",
incorporation_number="BC1234567",
order_number="SO-00123",
pdf_paths=["/tmp/cert.pdf", "/tmp/articles.pdf", "/tmp/crtc.pdf"],
output_dir="/tmp/output",
)
"""
from __future__ import annotations
import logging
import os
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("workers.binder_compiler")
# PW branding colors
NAVY_BLUE = (0.11, 0.18, 0.33) # RGB normalized: #1C2E54
WHITE = (1.0, 1.0, 1.0)
LIGHT_GRAY = (0.85, 0.85, 0.85)
DARK_GRAY = (0.25, 0.25, 0.25)
# Default section order for CRTC corporate binder
DEFAULT_SECTIONS = [
"Certificate of Incorporation",
"Articles of Incorporation",
"CRTC Notification Letter",
"Registered Office Agreement",
"Director Consent(s)",
"Share Structure",
"Corporate Bylaws",
"Miscellaneous",
]
class BinderCompiler:
"""Compiles multiple PDF documents into a single corporate binder."""
def compile(
self,
entity_name: str,
incorporation_number: str,
order_number: str,
pdf_paths: list[str],
output_dir: str | None = None,
sections: list[str] | None = None,
) -> Optional[str]:
"""Compile PDFs into a corporate binder.
Args:
entity_name: Name of the BC corporation.
incorporation_number: Provincial incorporation number.
order_number: ERPNext order number.
pdf_paths: List of PDF file paths to include.
output_dir: Directory for the output binder. Defaults to temp dir.
sections: Optional list of section names (for dividers/TOC).
Defaults to DEFAULT_SECTIONS, matched to pdf_paths by index.
Returns:
Path to the compiled binder PDF, or None on failure.
"""
if not pdf_paths:
LOG.warning("No PDFs provided — cannot compile binder")
return None
output_dir = output_dir or tempfile.mkdtemp(prefix="pw_binder_")
os.makedirs(output_dir, exist_ok=True)
section_names = sections or DEFAULT_SECTIONS
binder_filename = f"corporate-binder-{order_number}.pdf"
binder_path = os.path.join(output_dir, binder_filename)
LOG.info(
"Compiling binder for %s (BC# %s, order %s) — %d documents",
entity_name, incorporation_number, order_number, len(pdf_paths),
)
try:
import pikepdf
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas as rl_canvas
# ---------------------------------------------------------- #
# Step 1: Generate cover page
# ---------------------------------------------------------- #
cover_path = os.path.join(output_dir, "_cover.pdf")
self._generate_cover_page(
output_path=cover_path,
entity_name=entity_name,
incorporation_number=incorporation_number,
order_number=order_number,
)
# ---------------------------------------------------------- #
# Step 2: Build section list with divider pages
# ---------------------------------------------------------- #
# Map each PDF to a section name
section_map: list[tuple[str, str]] = []
for i, pdf_path in enumerate(pdf_paths):
if not Path(pdf_path).exists():
LOG.warning("PDF not found, skipping: %s", pdf_path)
continue
section_name = (
section_names[i] if i < len(section_names)
else f"Document {i + 1}"
)
section_map.append((section_name, pdf_path))
if not section_map:
LOG.warning("No valid PDFs found — cannot compile binder")
return None
# ---------------------------------------------------------- #
# Step 3: Generate TOC page
# ---------------------------------------------------------- #
toc_path = os.path.join(output_dir, "_toc.pdf")
self._generate_toc_page(
output_path=toc_path,
entity_name=entity_name,
sections=[name for name, _ in section_map],
)
# ---------------------------------------------------------- #
# Step 4: Generate divider pages for each section
# ---------------------------------------------------------- #
divider_paths: list[str] = []
for i, (section_name, _) in enumerate(section_map):
divider_path = os.path.join(output_dir, f"_divider_{i:02d}.pdf")
self._generate_divider_page(
output_path=divider_path,
section_name=section_name,
section_number=i + 1,
)
divider_paths.append(divider_path)
# ---------------------------------------------------------- #
# Step 5: Merge everything with pikepdf
# ---------------------------------------------------------- #
merged = pikepdf.Pdf.new()
# Add cover page
with pikepdf.open(cover_path) as cover_pdf:
merged.pages.extend(cover_pdf.pages)
# Add TOC page
with pikepdf.open(toc_path) as toc_pdf:
merged.pages.extend(toc_pdf.pages)
# Add each section: divider + content
for i, (section_name, pdf_path) in enumerate(section_map):
# Add divider
with pikepdf.open(divider_paths[i]) as divider_pdf:
merged.pages.extend(divider_pdf.pages)
# Add content PDF
try:
with pikepdf.open(pdf_path) as content_pdf:
merged.pages.extend(content_pdf.pages)
LOG.info(
"Added section '%s' (%d pages) from %s",
section_name,
len(content_pdf.pages),
Path(pdf_path).name,
)
except Exception as exc:
LOG.error("Failed to merge %s: %s", pdf_path, exc)
# Save the merged PDF (without page numbers yet)
merged_no_numbers_path = os.path.join(output_dir, "_merged_no_numbers.pdf")
merged.save(merged_no_numbers_path)
merged.close()
# ---------------------------------------------------------- #
# Step 6: Add page numbers to every page
# ---------------------------------------------------------- #
self._add_page_numbers(
input_path=merged_no_numbers_path,
output_path=binder_path,
)
# Clean up intermediate files
for cleanup_path in [cover_path, toc_path, merged_no_numbers_path] + divider_paths:
try:
os.unlink(cleanup_path)
except OSError:
pass
LOG.info("Binder compiled: %s", binder_path)
return binder_path
except ImportError as exc:
LOG.error(
"Missing dependency for binder compilation: %s. "
"Install with: pip install pikepdf reportlab",
exc,
)
return None
except Exception as exc:
LOG.exception("Binder compilation failed: %s", exc)
return None
# ------------------------------------------------------------------ #
# Page generators (reportlab)
# ------------------------------------------------------------------ #
def _generate_cover_page(
self,
output_path: str,
entity_name: str,
incorporation_number: str,
order_number: str,
) -> None:
"""Generate a branded cover page PDF."""
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas as rl_canvas
width, height = letter
c = rl_canvas.Canvas(output_path, pagesize=letter)
# Navy background
c.setFillColorRGB(*NAVY_BLUE)
c.rect(0, 0, width, height, fill=1, stroke=0)
# White text
c.setFillColorRGB(*WHITE)
# PW branding
c.setFont("Helvetica-Bold", 16)
c.drawCentredString(width / 2, height - 1.5 * inch, "PERFORMANCE WEST INC.")
c.setFont("Helvetica", 11)
c.drawCentredString(width / 2, height - 1.85 * inch, "Corporate Services")
# Horizontal rule
c.setStrokeColorRGB(*WHITE)
c.setLineWidth(1)
c.line(2 * inch, height - 2.2 * inch, width - 2 * inch, height - 2.2 * inch)
# Title
c.setFont("Helvetica-Bold", 28)
c.drawCentredString(width / 2, height / 2 + 0.8 * inch, "CORPORATE BINDER")
# Entity name
c.setFont("Helvetica-Bold", 22)
# Handle long entity names
if len(entity_name) > 35:
c.setFont("Helvetica-Bold", 18)
c.drawCentredString(width / 2, height / 2, entity_name)
# BC number
c.setFont("Helvetica", 14)
c.drawCentredString(width / 2, height / 2 - 0.5 * inch, f"Incorporation No. {incorporation_number}")
# Date
c.setFont("Helvetica", 12)
date_str = datetime.utcnow().strftime("%B %d, %Y")
c.drawCentredString(width / 2, height / 2 - 1.0 * inch, date_str)
# Order number at bottom
c.setFont("Helvetica", 10)
c.drawCentredString(width / 2, 1.0 * inch, f"Order: {order_number}")
# Bottom rule
c.line(2 * inch, 1.4 * inch, width - 2 * inch, 1.4 * inch)
c.setFont("Helvetica", 9)
c.drawCentredString(width / 2, 0.65 * inch, "Confidential — Prepared by Performance West Inc.")
c.save()
LOG.info("Cover page generated: %s", output_path)
def _generate_toc_page(
self,
output_path: str,
entity_name: str,
sections: list[str],
) -> None:
"""Generate a table of contents page PDF."""
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas as rl_canvas
width, height = letter
c = rl_canvas.Canvas(output_path, pagesize=letter)
# White background (default)
c.setFillColorRGB(*DARK_GRAY)
c.setFont("Helvetica-Bold", 22)
c.drawCentredString(width / 2, height - 1.2 * inch, "TABLE OF CONTENTS")
# Subtitle
c.setFont("Helvetica", 12)
c.setFillColorRGB(0.4, 0.4, 0.4)
c.drawCentredString(width / 2, height - 1.6 * inch, entity_name)
# Horizontal rule
c.setStrokeColorRGB(*LIGHT_GRAY)
c.setLineWidth(1)
c.line(1.2 * inch, height - 1.9 * inch, width - 1.2 * inch, height - 1.9 * inch)
# Section entries
y_position = height - 2.5 * inch
c.setFillColorRGB(*DARK_GRAY)
for i, section_name in enumerate(sections):
section_num = i + 1
# Section number
c.setFont("Helvetica-Bold", 13)
c.drawString(1.5 * inch, y_position, f"Section {section_num}")
# Section name
c.setFont("Helvetica", 13)
c.drawString(3.0 * inch, y_position, section_name)
# Dotted leader line (decorative)
c.setStrokeColorRGB(*LIGHT_GRAY)
c.setDash(1, 3)
c.line(
3.0 * inch + c.stringWidth(section_name, "Helvetica", 13) + 10,
y_position + 2,
width - 1.5 * inch,
y_position + 2,
)
c.setDash() # Reset dash
y_position -= 0.45 * inch
if y_position < 1.5 * inch:
# Overflow protection — very unlikely with typical binders
break
c.save()
LOG.info("TOC page generated: %s", output_path)
def _generate_divider_page(
self,
output_path: str,
section_name: str,
section_number: int,
) -> None:
"""Generate a colored tab divider page (navy blue bg, white text)."""
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas as rl_canvas
width, height = letter
c = rl_canvas.Canvas(output_path, pagesize=letter)
# Navy blue background
c.setFillColorRGB(*NAVY_BLUE)
c.rect(0, 0, width, height, fill=1, stroke=0)
# White text
c.setFillColorRGB(*WHITE)
# Section number
c.setFont("Helvetica", 16)
c.drawCentredString(width / 2, height / 2 + 1.0 * inch, f"SECTION {section_number}")
# Horizontal rule
c.setStrokeColorRGB(*WHITE)
c.setLineWidth(2)
c.line(2.5 * inch, height / 2 + 0.6 * inch, width - 2.5 * inch, height / 2 + 0.6 * inch)
# Section title
font_size = 32
if len(section_name) > 25:
font_size = 24
if len(section_name) > 40:
font_size = 18
c.setFont("Helvetica-Bold", font_size)
c.drawCentredString(width / 2, height / 2 - 0.2 * inch, section_name)
c.save()
LOG.info("Divider page generated: Section %d%s", section_number, section_name)
# ------------------------------------------------------------------ #
# Page numbering
# ------------------------------------------------------------------ #
def _add_page_numbers(self, input_path: str, output_path: str) -> None:
"""Add page numbers (centered footer) to every page of a PDF.
Uses pikepdf to read the merged PDF and reportlab to generate
a page-number overlay, then stamps each page.
"""
import pikepdf
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas as rl_canvas
source = pikepdf.open(input_path)
total_pages = len(source.pages)
for page_num in range(total_pages):
# Generate a single-page overlay with the page number
overlay_path = os.path.join(
os.path.dirname(output_path),
f"_pagenum_{page_num:04d}.pdf",
)
# Get page dimensions
page = source.pages[page_num]
mediabox = page.get("/MediaBox")
if mediabox:
pg_width = float(mediabox[2]) - float(mediabox[0])
pg_height = float(mediabox[3]) - float(mediabox[1])
else:
pg_width, pg_height = letter
c = rl_canvas.Canvas(overlay_path, pagesize=(pg_width, pg_height))
c.setFillColorRGB(0.45, 0.45, 0.45)
c.setFont("Helvetica", 9)
c.drawCentredString(
pg_width / 2,
0.4 * inch,
f"Page {page_num + 1} of {total_pages}",
)
c.save()
# Stamp the overlay onto the source page
overlay_pdf = pikepdf.open(overlay_path)
overlay_page = overlay_pdf.pages[0]
# Merge the overlay page contents onto the source page
if "/Contents" in overlay_page:
source_page = source.pages[page_num]
# Use pikepdf's page overlay approach
source_page.add_overlay(overlay_page)
overlay_pdf.close()
# Clean up overlay file
try:
os.unlink(overlay_path)
except OSError:
pass
source.save(output_path)
source.close()
LOG.info("Page numbers added to %d pages: %s", total_pages, output_path)