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>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
466
scripts/workers/binder_compiler.py
Normal file
466
scripts/workers/binder_compiler.py
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
"""
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue