""" 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)