# === PDF GENERATION === # Converts a Django HTML template into a PDF file using WeasyPrint. # Used for payslip, receipt, and payroll report PDFs (both email and # browser download). # # Why WeasyPrint? # ---------------- # Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade # HTML-to-PDF renderer — it supports modern CSS features that xhtml2pdf # could not: flexbox, grid, @font-face (custom web fonts), box-shadow, # border-radius, transforms, and proper CSS cascade handling. # # WeasyPrint has system dependencies (Pango, Cairo, GDK-PixBuf) that # xhtml2pdf did not need. On Flatlogic's Linux environment these are # already installed system-wide — no extra setup. On Windows dev # machines, we install the GTK3 runtime (via `winget install # tschoonj.GTKForWindows`) and then the `_ensure_gtk_on_windows()` # helper below tells Python where to find the DLLs. # # Why the Windows DLL dance? # -------------------------- # Since Python 3.8, `PATH` alone is no longer sufficient to load native # DLLs — Python requires explicit `os.add_dll_directory()` calls for # security reasons. This helper walks common GTK3 install locations # and registers the first one found. On Linux it's a no-op. # # IMPORTANT: WeasyPrint is imported LAZILY (inside the function, not # at the top of the file). This is intentional — if WeasyPrint or its # system libraries are missing, the rest of the app still works; # only PDF generation fails gracefully and returns None. import logging import os import sys from django.conf import settings from django.template.loader import get_template logger = logging.getLogger(__name__) # Common install locations for the GTK3 runtime on Windows. # Checked in order; first hit wins. _WINDOWS_GTK_PATHS = ( r"C:\Program Files\GTK3-Runtime Win64\bin", r"C:\Program Files (x86)\GTK3-Runtime Win64\bin", ) # Module-level flag so we only add the DLL directory once per process. _gtk_registered = False def _ensure_gtk_on_windows(): """Register the GTK3 runtime DLL directory on Windows. WeasyPrint loads DLLs two ways: 1. `ctypes.util.find_library('gobject-2.0-0')` — reads os.environ['PATH'] 2. `ctypes.CDLL(...)` — uses Windows DLL search, affected by `os.add_dll_directory()` since Python 3.8 We hit both paths so the library is discoverable regardless of which route WeasyPrint chooses. This is a no-op on Linux/macOS and on Windows when GTK is already registered. """ global _gtk_registered if _gtk_registered or sys.platform != "win32": return for path in _WINDOWS_GTK_PATHS: if os.path.isdir(path): # Prepend to PATH so find_library() locates the DLLs. current_path = os.environ.get("PATH", "") if path.lower() not in current_path.lower(): os.environ["PATH"] = path + os.pathsep + current_path # Register via add_dll_directory for ctypes.CDLL() loads. if hasattr(os, "add_dll_directory"): os.add_dll_directory(path) _gtk_registered = True logger.debug("Registered GTK3 runtime directory: %s", path) return logger.debug( "No GTK3 runtime directory found on Windows in %s", _WINDOWS_GTK_PATHS, ) def render_to_pdf(template_src, context_dict=None): """ Render a Django template to PDF bytes using WeasyPrint. Args: template_src: Path to the template (e.g. 'core/pdf/payslip_pdf.html') context_dict: Template context variables Returns: PDF content as bytes, or None if there was an error. """ if context_dict is None: context_dict = {} # On Windows we need to tell Python where the GTK3 DLLs live # BEFORE importing weasyprint. Harmless no-op on Linux/macOS. _ensure_gtk_on_windows() # --- Lazy import: only load WeasyPrint when actually generating a PDF --- # ImportError covers the Python package being missing. # OSError covers missing native libs (Pango, Cairo) at runtime. try: from weasyprint import HTML except (ImportError, OSError) as e: logger.error( "WeasyPrint is not available — cannot generate PDF. " "Install with: pip install weasyprint. " "On Windows, also install the GTK3 runtime via " "`winget install tschoonj.GTKForWindows`. " "Underlying error: %s", e, ) return None # Render the Django template to HTML first template = get_template(template_src) html = template.render(context_dict) # Convert HTML to PDF bytes. # base_url lets WeasyPrint resolve relative paths to static files # (e.g. fonts in static/fonts/, images in static/img/). We fall back # to "." so the call still succeeds when STATIC_ROOT isn't set # (e.g. during local dev before collectstatic has run). base = getattr(settings, "STATIC_ROOT", None) or "." try: return HTML(string=html, base_url=base).write_pdf() except Exception as e: logger.exception("WeasyPrint render failed: %s", e) return None