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>
466 lines
16 KiB
Python
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)
|