""" PDF Generation utility for invoices and quotes Uses ReportLab to generate professional PDF documents Note: This module has been migrated from WeasyPrint to ReportLab for better reliability and fewer system dependencies. Legacy WeasyPrint imports remain for backward compatibility but are not actively used in the new implementation. """ import html as html_lib import os from datetime import datetime try: # Try importing WeasyPrint. This may fail on systems without native deps. from weasyprint import CSS, HTML # type: ignore from weasyprint.text.fonts import FontConfiguration # type: ignore _WEASYPRINT_AVAILABLE = True except Exception: # Defer to fallback implementation at runtime HTML = None # type: ignore CSS = None # type: ignore FontConfiguration = None # type: ignore _WEASYPRINT_AVAILABLE = False from flask import current_app from flask_babel import gettext as _ from app import db from app.models import InvoicePDFTemplate, QuotePDFTemplate, Settings try: from babel.dates import format_date as babel_format_date except Exception: babel_format_date = None from pathlib import Path from flask import render_template def update_page_size_in_css(css_text, page_size): """ Update @page size property to match the specified page size. This function handles: - Replacing existing @page size property - Adding @page size property if missing - Handling nested @page rules (e.g., @bottom-center) - Multiple @page rules (updates all of them) Args: css_text: CSS string that may contain @page rules page_size: Target page size (e.g., "A4", "Letter") Returns: Updated CSS string with correct @page size """ import re if not css_text or not page_size: return css_text # Find all @page rules (may have multiple) page_pattern = r"@page\s*\{" matches = list(re.finditer(page_pattern, css_text, re.IGNORECASE | re.MULTILINE)) if not matches: # No @page rule exists - add one at the beginning new_page_rule = f"@page {{\n size: {page_size};\n margin: 2cm;\n }}\n\n" return new_page_rule + css_text # Process matches in reverse order to maintain positions for match in reversed(matches): start_pos = match.start() # Find matching closing brace, accounting for nested braces brace_count = 0 end_pos = len(css_text) for i in range(match.end() - 1, len(css_text)): if css_text[i] == "{": brace_count += 1 elif css_text[i] == "}": brace_count -= 1 if brace_count == 0: end_pos = i + 1 break page_block = css_text[start_pos:end_pos] # Replace or add size property if re.search(r"size\s*:", page_block, re.IGNORECASE): # Replace existing size property - handle any whitespace, quotes, and values # Match: size: "A5" or size: A5 or size:A5 etc. updated_block = re.sub( r"size\s*:\s*['\"]?[^;}\n]+['\"]?", f"size: {page_size}", page_block, flags=re.IGNORECASE | re.MULTILINE, ) css_text = css_text[:start_pos] + updated_block + css_text[end_pos:] else: # Add size property after @page { updated_block = re.sub( r"(@page\s*\{)", r"\1\n size: " + page_size + r";", page_block, count=1, flags=re.IGNORECASE, ) css_text = css_text[:start_pos] + updated_block + css_text[end_pos:] return css_text def update_wrapper_dimensions_in_css(css_text, page_size): """ Update wrapper dimensions (width, height, max-width, max-height) in CSS to match page size. This function updates the .invoice-wrapper and .quote-wrapper dimensions to match the selected page size. Dimensions are calculated at 72 DPI for PDF. Args: css_text: CSS string that may contain wrapper dimension definitions page_size: Target page size (e.g., "A4", "A5", "Letter") Returns: Updated CSS string with correct wrapper dimensions """ if not css_text or not page_size: return css_text # Standard page sizes (shared by both InvoicePDFTemplate and QuotePDFTemplate) PAGE_SIZES = { "A4": {"width": 210, "height": 297}, "Letter": {"width": 216, "height": 279}, "Legal": {"width": 216, "height": 356}, "A3": {"width": 297, "height": 420}, "A5": {"width": 148, "height": 210}, "Tabloid": {"width": 279, "height": 432}, } # Get page dimensions page_dimensions = PAGE_SIZES.get(page_size) if not page_dimensions: return css_text # Calculate dimensions in pixels at 72 DPI (PDF standard) width_mm = page_dimensions["width"] height_mm = page_dimensions["height"] width_px = int((width_mm / 25.4) * 72) height_px = int((height_mm / 25.4) * 72) import re # Pattern to match wrapper dimension properties # Match: width: 420px, width:420px, width: 420px !important, etc. dimension_patterns = [ (r"\.invoice-wrapper\s*\{[^}]*?)(width\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{width_px}px\\3"), (r"\.invoice-wrapper\s*\{[^}]*?)(height\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{height_px}px\\3"), (r"\.invoice-wrapper\s*\{[^}]*?)(max-width\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{width_px}px\\3"), (r"\.invoice-wrapper\s*\{[^}]*?)(max-height\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{height_px}px\\3"), (r"\.invoice-wrapper\s*\{[^}]*?)(min-width\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{width_px}px\\3"), (r"\.invoice-wrapper\s*\{[^}]*?)(min-height\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{height_px}px\\3"), (r"\.quote-wrapper\s*\{[^}]*?)(width\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{width_px}px\\3"), (r"\.quote-wrapper\s*\{[^}]*?)(height\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{height_px}px\\3"), (r"\.quote-wrapper\s*\{[^}]*?)(max-width\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{width_px}px\\3"), (r"\.quote-wrapper\s*\{[^}]*?)(max-height\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{height_px}px\\3"), (r"\.quote-wrapper\s*\{[^}]*?)(min-width\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{width_px}px\\3"), (r"\.quote-wrapper\s*\{[^}]*?)(min-height\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{height_px}px\\3"), ] updated_css = css_text for pattern, replacement in dimension_patterns: updated_css = re.sub(pattern, replacement, updated_css, flags=re.IGNORECASE | re.DOTALL) # Also update html, body dimensions if they exist updated_css = re.sub( r"(html,\s*body\s*\{[^}]*?)(width\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{width_px}px\\3", updated_css, flags=re.IGNORECASE | re.DOTALL, ) updated_css = re.sub( r"(html,\s*body\s*\{[^}]*?)(height\s*:\s*)\d+px(\s*!important)?", f"\\1\\2{height_px}px\\3", updated_css, flags=re.IGNORECASE | re.DOTALL, ) return updated_css def validate_page_size_in_css(css_text, expected_page_size): """ Validate that CSS contains the correct @page size. Args: css_text: CSS string to validate expected_page_size: Expected page size (e.g., "A4", "Letter") Returns: tuple: (is_valid: bool, found_sizes: list) - True if all @page rules have correct size """ import re if not css_text or not expected_page_size: return False, [] # Find all @page rules and check their size page_rules = re.findall(r"@page\s*\{[^}]*\}", css_text, re.IGNORECASE | re.DOTALL) found_sizes = [] for rule in page_rules: size_match = re.search(r"size\s*:\s*['\"]?([^;}\n'\"]+)['\"]?", rule, re.IGNORECASE) if size_match: found_size = size_match.group(1).strip() found_sizes.append(found_size) # Remove quotes if present (double-check) found_size = found_size.strip("\"'") if found_size != expected_page_size: return False, found_sizes # If we found @page rules, all should have the correct size if page_rules and not found_sizes: return False, [] # @page rules exist but no size specified # If no @page rules, that's also a problem if not page_rules: return False, [] return True, found_sizes class InvoicePDFGenerator: """Generate PDF invoices with company branding""" def __init__(self, invoice, settings=None, page_size="A4"): self.invoice = invoice self.settings = settings or Settings.get_settings() self.page_size = page_size or "A4" def generate_pdf(self): """Generate PDF content and return as bytes using ReportLab""" import json import sys from flask import current_app def debug_print(msg): """Print debug message to stdout with immediate flush for Docker visibility""" print(msg, file=sys.stdout, flush=True) print(msg, file=sys.stderr, flush=True) # Also log using Flask logger if available try: current_app.logger.info(msg) except Exception: pass invoice_id = getattr(self.invoice, "id", "N/A") invoice_number = getattr(self.invoice, "invoice_number", "N/A") debug_print( f"\n[PDF_EXPORT] PDF GENERATOR - InvoiceID: {invoice_id}, InvoiceNumber: {invoice_number}, PageSize: {self.page_size}" ) debug_print(f"{'='*80}\n") current_app.logger.info( f"[PDF_EXPORT] Starting PDF generation - InvoiceID: {invoice_id}, InvoiceNumber: {invoice_number}, PageSize: '{self.page_size}'" ) # Get template for the specified page size from app.models import InvoicePDFTemplate # CRITICAL: Expire all cached objects to ensure we get the latest saved template db.session.expire_all() current_app.logger.info( f"[PDF_EXPORT] Querying database for template - PageSize: '{self.page_size}', InvoiceID: {invoice_id}" ) # CRITICAL: Do a completely fresh query using raw SQL to bypass any ORM caching # This ensures we get the absolute latest data from the database from sqlalchemy import text result = db.session.execute( text( "SELECT id, page_size, template_json, updated_at FROM invoice_pdf_templates WHERE page_size = :page_size" ), {"page_size": self.page_size}, ).first() template_json_raw_from_db = None template = None if result: template_id, page_size_db, template_json_raw_from_db, updated_at = result current_app.logger.info( f"[PDF_EXPORT] Template found via raw query - PageSize: '{page_size_db}', TemplateID: {template_id}, UpdatedAt: {updated_at}, TemplateJSONLength: {len(template_json_raw_from_db) if template_json_raw_from_db else 0}, InvoiceID: {invoice_id}" ) # Now get the full template object for use (for other attributes if needed) template = InvoicePDFTemplate.query.get(template_id) # CRITICAL: Use template_json directly from raw query, not from ORM object (which might be cached) if template_json_raw_from_db: template.template_json = template_json_raw_from_db # Force refresh all other attributes db.session.refresh(template) else: current_app.logger.warning( f"[PDF_EXPORT] Template not found for PageSize: '{self.page_size}', creating default - InvoiceID: {invoice_id}" ) template = InvoicePDFTemplate.get_template(self.page_size) template_json_raw_from_db = template.template_json # Store template as instance variable for use in format_date self.template = template debug_print(f"[DEBUG] Retrieved template: page_size={template.page_size}, id={template.id}") template_json_to_use = template_json_raw_from_db if template_json_raw_from_db else template.template_json template_json_length = len(template_json_to_use) if template_json_to_use else 0 template_json_preview = ( (template_json_to_use[:100] + "...") if template_json_to_use and len(template_json_to_use) > 100 else (template_json_to_use or "(empty)") ) # Also get a hash/fingerprint of the JSON to verify it's actually the saved one import hashlib template_json_hash = ( hashlib.md5(template_json_to_use.encode("utf-8")).hexdigest()[:16] if template_json_to_use else "none" ) current_app.logger.info( f"[PDF_EXPORT] Template retrieved - PageSize: '{template.page_size}', TemplateID: {template.id}, HasJSON: {bool(template_json_to_use)}, JSONLength: {template_json_length}, JSONHash: {template_json_hash}, JSONPreview: {template_json_preview}, UpdatedAt: {template.updated_at}, InvoiceID: {invoice_id}" ) # Get or generate ReportLab template JSON template_json_dict = None # CRITICAL: Use template_json_raw_from_db (from raw query) - this is the absolute latest from database # template_json_to_use is already set above # Check if template_json exists and is not empty/whitespace if template_json_to_use and template_json_to_use.strip(): try: current_app.logger.info( f"[PDF_EXPORT] Parsing template JSON - PageSize: '{self.page_size}', JSON length: {len(template_json_to_use)}, InvoiceID: {invoice_id}" ) template_json_dict = json.loads(template_json_to_use) element_count = len(template_json_dict.get("elements", [])) json_page_size = template_json_dict.get("page", {}).get("size", "unknown") # Get first few element types for debugging element_types = [elem.get("type", "unknown") for elem in template_json_dict.get("elements", [])[:5]] debug_print(f"[DEBUG] Found ReportLab template JSON (length: {len(template_json_to_use)})") current_app.logger.info( f"[PDF_EXPORT] Template JSON parsed successfully - PageSize: '{self.page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, FirstElementTypes: {element_types}, InvoiceID: {invoice_id}" ) except Exception as e: debug_print(f"[WARNING] Failed to parse template_json: {e}") template_json_preview_use = ( (template_json_to_use[:100] + "...") if template_json_to_use and len(template_json_to_use) > 100 else (template_json_to_use or "(empty)") ) current_app.logger.error( f"[PDF_EXPORT] Failed to parse template JSON - PageSize: '{self.page_size}', Error: {str(e)}, JSONPreview: {template_json_preview_use}, InvoiceID: {invoice_id}", exc_info=True, ) template_json_dict = None else: current_app.logger.warning( f"[PDF_EXPORT] Template JSON is empty or whitespace - PageSize: '{self.page_size}', TemplateID: {template.id}, TemplateJSONIsNone: {template_json_to_use is None}, TemplateJSONIsEmpty: {not template_json_to_use or not template_json_to_use.strip()}, RawQueryResult: {template_json_raw_from_db is not None if 'template_json_raw_from_db' in locals() else 'N/A'}, InvoiceID: {invoice_id}" ) # If no JSON template exists, ensure it's populated with default (will save to database if empty) if not template_json_dict: debug_print( f"[DEBUG] No template JSON found, ensuring default template JSON for page size {self.page_size}" ) current_app.logger.info( f"[PDF_EXPORT] Template JSON is empty, ensuring default template - PageSize: '{self.page_size}', " f"TemplateID: {template.id}, InvoiceID: {invoice_id}" ) # Call ensure_template_json() which will populate with default if empty/invalid # This saves the default to the database, so it's available for future exports # It only saves if template_json is truly empty/invalid, not if it's a valid custom template template.ensure_template_json() # Re-query template_json from database to get the updated value (avoid ORM caching) db.session.expire(template) result_updated = db.session.execute( text("SELECT template_json FROM invoice_pdf_templates WHERE id = :template_id"), {"template_id": template.id}, ).first() if result_updated and result_updated[0]: template_json_to_use = result_updated[0] try: template_json_dict = json.loads(template_json_to_use) element_count = len(template_json_dict.get("elements", [])) debug_print(f"[DEBUG] Retrieved default template JSON with {element_count} elements (saved to DB)") current_app.logger.info( f"[PDF_EXPORT] Default template JSON retrieved from database - PageSize: '{self.page_size}', " f"Elements: {element_count}, InvoiceID: {invoice_id}" ) except Exception as e: current_app.logger.error( f"[PDF_EXPORT] Failed to parse template JSON after ensure_template_json() - PageSize: '{self.page_size}', Error: {str(e)}, InvoiceID: {invoice_id}", exc_info=True, ) # Fall back to generating default in memory if parsing fails from app.utils.pdf_template_schema import get_default_template template_json_dict = get_default_template(self.page_size) else: # Fallback: generate default in memory if ensure_template_json() didn't work current_app.logger.warning( f"[PDF_EXPORT] ensure_template_json() didn't populate template_json, using in-memory default - PageSize: '{self.page_size}', TemplateID: {template.id}, InvoiceID: {invoice_id}" ) from app.utils.pdf_template_schema import get_default_template template_json_dict = get_default_template(self.page_size) else: # CRITICAL: Ensure template page size and dimensions match the requested page size # This fixes layout issues when templates were customized but dimensions don't match template_page_config = template_json_dict.get("page", {}) template_page_size = template_page_config.get("size", self.page_size) if template_page_size != self.page_size: current_app.logger.warning( f"[PDF_EXPORT] Template page size mismatch - TemplatePageSize: '{template_page_size}', " f"RequestedPageSize: '{self.page_size}', InvoiceID: {invoice_id}. " f"Updating template to match requested page size." ) # Update template page size to match requested size template_page_config["size"] = self.page_size template_json_dict["page"] = template_page_config # Ensure page dimensions are correct for the requested page size from app.utils.pdf_template_schema import PAGE_SIZE_DIMENSIONS_MM if self.page_size in PAGE_SIZE_DIMENSIONS_MM: expected_dims = PAGE_SIZE_DIMENSIONS_MM[self.page_size] current_width = template_page_config.get("width") current_height = template_page_config.get("height") if current_width != expected_dims["width"] or current_height != expected_dims["height"]: current_app.logger.info( f"[PDF_EXPORT] Correcting template page dimensions - PageSize: '{self.page_size}', " f"Old: {current_width}x{current_height}mm, New: {expected_dims['width']}x{expected_dims['height']}mm, InvoiceID: {invoice_id}" ) template_page_config["width"] = expected_dims["width"] template_page_config["height"] = expected_dims["height"] template_json_dict["page"] = template_page_config # Update element positions if they exceed page bounds (due to page size change) # This helps fix layout issues when switching between page sizes if template_page_size != self.page_size: page_dims = PAGE_SIZE_DIMENSIONS_MM.get(self.page_size, {"width": 210, "height": 297}) page_width_pt = (page_dims["width"] / 25.4) * 72 # Convert mm to points page_height_pt = (page_dims["height"] / 25.4) * 72 elements = template_json_dict.get("elements", []) adjusted_count = 0 for element in elements: x = element.get("x", 0) y = element.get("y", 0) width = element.get("width", 0) height = element.get("height", 0) # Check if element is outside page bounds if x + width > page_width_pt or y + height > page_height_pt: # Scale element to fit within page (proportional scaling) if x + width > page_width_pt: scale_x = (page_width_pt - 20) / (x + width) # Leave 20pt margin element["x"] = x * scale_x element["width"] = width * scale_x adjusted_count += 1 if y + height > page_height_pt: scale_y = (page_height_pt - 20) / (y + height) # Leave 20pt margin element["y"] = y * scale_y element["height"] = height * scale_y adjusted_count += 1 if adjusted_count > 0: current_app.logger.info( f"[PDF_EXPORT] Adjusted {adjusted_count} elements to fit page size '{self.page_size}' - InvoiceID: {invoice_id}" ) # Always use ReportLab template renderer with JSON debug_print(f"[DEBUG] Using ReportLab template renderer for page size {self.page_size}") from app.utils.pdf_generator_reportlab import ReportLabTemplateRenderer from app.utils.pdf_template_schema import validate_template_json # Validate template JSON current_app.logger.info( f"[PDF_EXPORT] Validating template JSON - PageSize: '{self.page_size}', InvoiceID: {invoice_id}" ) is_valid, error = validate_template_json(template_json_dict) if not is_valid: debug_print(f"[ERROR] Template JSON validation failed: {error}") current_app.logger.error( f"[PDF_EXPORT] Template JSON validation failed - PageSize: '{self.page_size}', Error: {error}, InvoiceID: {invoice_id}" ) # Even if validation fails, try to render with default fallback return self._generate_pdf_with_default() else: current_app.logger.info( f"[PDF_EXPORT] Template JSON validation passed - PageSize: '{self.page_size}', InvoiceID: {invoice_id}" ) # Prepare data context for template rendering current_app.logger.info( f"[PDF_EXPORT] Preparing template context - PageSize: '{self.page_size}', InvoiceID: {invoice_id}" ) data_context = self._prepare_template_context() # Render PDF using ReportLab current_app.logger.info( f"[PDF_EXPORT] Creating ReportLab renderer - PageSize: '{self.page_size}', InvoiceID: {invoice_id}" ) renderer = ReportLabTemplateRenderer(template_json_dict, data_context, self.page_size) try: current_app.logger.info( f"[PDF_EXPORT] Starting ReportLab render - PageSize: '{self.page_size}', InvoiceID: {invoice_id}" ) pdf_bytes = renderer.render_to_bytes() pdf_size_bytes = len(pdf_bytes) debug_print(f"[DEBUG] ReportLab PDF generated successfully - size: {pdf_size_bytes} bytes") current_app.logger.info( f"[PDF_EXPORT] ReportLab PDF generated successfully - PageSize: '{self.page_size}', PDFSize: {pdf_size_bytes} bytes, InvoiceID: {invoice_id}" ) return pdf_bytes except Exception as e: debug_print(f"[ERROR] ReportLab rendering failed: {e}") import traceback debug_print(traceback.format_exc()) current_app.logger.error( f"[PDF_EXPORT] ReportLab rendering failed - PageSize: '{self.page_size}', Error: {str(e)}, InvoiceID: {invoice_id}", exc_info=True, ) # Fall back to default generation return self._generate_pdf_with_default() def _prepare_template_context(self): """Prepare data context for template rendering""" # Convert SQLAlchemy objects to simple structures for template from types import SimpleNamespace # Create invoice wrapper invoice_wrapper = SimpleNamespace() for attr in [ "id", "invoice_number", "issue_date", "due_date", "status", "client_name", "client_email", "client_address", "client_id", "subtotal", "tax_rate", "tax_amount", "total_amount", "notes", "terms", ]: try: setattr(invoice_wrapper, attr, getattr(self.invoice, attr)) except AttributeError: pass # Convert relationships to lists try: if hasattr(self.invoice.items, "all"): invoice_wrapper.items = self.invoice.items.all() else: invoice_wrapper.items = list(self.invoice.items) if self.invoice.items else [] except Exception: invoice_wrapper.items = [] try: if hasattr(self.invoice.extra_goods, "all"): invoice_wrapper.extra_goods = self.invoice.extra_goods.all() else: invoice_wrapper.extra_goods = list(self.invoice.extra_goods) if self.invoice.extra_goods else [] except Exception: invoice_wrapper.extra_goods = [] try: if hasattr(self.invoice, "expenses") and hasattr(self.invoice.expenses, "all"): invoice_wrapper.expenses = self.invoice.expenses.all() else: invoice_wrapper.expenses = ( list(self.invoice.expenses) if hasattr(self.invoice, "expenses") and self.invoice.expenses else [] ) except Exception: invoice_wrapper.expenses = [] # Build combined all_line_items for PDF table (items + extra_goods + expenses) # Each entry has: description, quantity, unit_price, total_amount all_line_items = [] for item in invoice_wrapper.items: all_line_items.append( SimpleNamespace( description=getattr(item, "description", str(item)) or "", quantity=getattr(item, "quantity", 1), unit_price=getattr(item, "unit_price", 0), total_amount=getattr(item, "total_amount", 0), ) ) for good in invoice_wrapper.extra_goods: desc_parts = [getattr(good, "name", str(good)) or ""] if getattr(good, "description", None): desc_parts.append(str(good.description)) if getattr(good, "sku", None): desc_parts.append(f"SKU: {good.sku}") if getattr(good, "category", None): desc_parts.append(f"Category: {good.category.title()}") all_line_items.append( SimpleNamespace( description="\n".join(desc_parts), quantity=getattr(good, "quantity", 1), unit_price=getattr(good, "unit_price", 0), total_amount=getattr(good, "total_amount", 0), ) ) for expense in invoice_wrapper.expenses: desc_parts = [getattr(expense, "title", str(expense)) or ""] if getattr(expense, "description", None): desc_parts.append(str(expense.description)) amt = getattr(expense, "total_amount", None) or getattr(expense, "amount", 0) all_line_items.append( SimpleNamespace( description="\n".join(desc_parts), quantity=1, unit_price=amt, total_amount=amt, ) ) invoice_wrapper.all_line_items = all_line_items # Project invoice_wrapper.project = self.invoice.project # Client (for PEPPOL compliance when setting is on) invoice_wrapper.client = getattr(self.invoice, "client", None) # Settings settings_wrapper = SimpleNamespace() for attr in [ "company_name", "company_address", "company_email", "company_phone", "company_website", "company_tax_id", "currency", "invoice_terms", "company_bank_info", ]: try: setattr(settings_wrapper, attr, getattr(self.settings, attr)) except AttributeError: pass # Add helper methods def has_logo(): return self.settings.has_logo() def get_logo_path(): return self.settings.get_logo_path() settings_wrapper.has_logo = has_logo settings_wrapper.get_logo_path = get_logo_path # Helper functions for templates from babel.dates import format_date as babel_format_date from app.utils.template_filters import get_image_base64, get_logo_base64 def format_date(value, format="medium"): try: # Use DD.MM.YYYY format for invoices and quotes return value.strftime("%d.%m.%Y") if value else "" except Exception: return str(value) if value else "" def format_money(value): try: return f"{float(value):,.2f} {self.settings.currency}" except Exception: return f"{value} {self.settings.currency}" # PEPPOL compliance: include when invoices_peppol_compliant is on result = { "invoice": invoice_wrapper, "settings": settings_wrapper, "get_logo_base64": get_logo_base64, "format_date": format_date, "format_money": format_money, } if getattr(self.settings, "invoices_peppol_compliant", False): client = getattr(self.invoice, "client", None) result["peppol_compliance"] = { "enabled": True, "seller_endpoint_id": (getattr(self.settings, "peppol_sender_endpoint_id", None) or "").strip(), "seller_scheme_id": (getattr(self.settings, "peppol_sender_scheme_id", None) or "").strip(), "seller_vat": (getattr(self.settings, "company_tax_id", None) or "").strip(), "buyer_endpoint_id": ( (client.get_custom_field("peppol_endpoint_id", "") or "").strip() if client else "" ), "buyer_scheme_id": (client.get_custom_field("peppol_scheme_id", "") or "").strip() if client else "", "buyer_vat": ( (client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "") or "").strip() if client else "" ), } return result def _generate_pdf_with_default(self): """Generate PDF using default fallback ReportLab generator""" from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback fallback = InvoicePDFGeneratorFallback(self.invoice, settings=self.settings) return fallback.generate_pdf() def _render_from_custom_template(self, template=None): """Render HTML and CSS from custom templates stored in database, with fallback to default template.""" # Define debug_print for this method scope import sys def debug_print(msg): """Print debug message to stdout with immediate flush for Docker visibility""" print(msg, file=sys.stdout, flush=True) print(msg, file=sys.stderr, flush=True) if template: # Ensure template matches the selected page size if hasattr(template, "page_size") and template.page_size != self.page_size: # Template doesn't match - this shouldn't happen, but handle it # Get the correct template from app.models import InvoicePDFTemplate correct_template = InvoicePDFTemplate.query.filter_by(page_size=self.page_size).first() if correct_template: template = correct_template else: # Couldn't find correct template - use default generation instead raise ValueError(f"Template for page size {self.page_size} not found") # Don't strip - preserve exact content as saved (whitespace might be important) html_template = template.template_html or "" css_template = template.template_css or "" else: # No template provided - this should not happen in normal flow # If it does, we can't proceed without a template raise ValueError(f"No template provided for page size {self.page_size}. This is a bug.") html = "" def update_page_size_in_html(html_text): """Update @page size property in HTML's inline )" if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL): html_text = re.sub(style_pattern, update_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL) return html_text def remove_page_rule_from_html(html_text): """Remove @page rules from HTML inline styles to avoid conflicts with separate CSS""" import re def remove_from_style_tag(match): style_content = match.group(2) # Remove @page rule from style content # Need to handle nested @bottom-center rules properly # Match @page { ... } including any nested rules brace_count = 0 page_pattern = r"@page\s*\{" page_match = re.search(page_pattern, style_content, re.IGNORECASE) if page_match: start = page_match.start() # Find matching closing brace pos = page_match.end() - 1 end = len(style_content) for i in range(page_match.end() - 1, len(style_content)): if style_content[i] == "{": brace_count += 1 elif style_content[i] == "}": brace_count -= 1 if brace_count == 0: end = i + 1 break # Remove the @page rule style_content = style_content[:start] + style_content[end:] # Clean up any double newlines or extra whitespace style_content = re.sub(r"\n\s*\n", "\n", style_content) return f"{match.group(1)}{style_content}{match.group(3)}" # Match )" if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL): html_text = re.sub(style_pattern, remove_from_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL) return html_text # Handle CSS: When both HTML (with inline styles) and separate CSS exist, # extract inline styles, merge with separate CSS, and remove from HTML to avoid conflicts import re css_to_use = "" html_inline_styles_extracted = False # Extract inline styles from HTML if present extracted_inline_css = "" if html_template and "", html_template, re.IGNORECASE | re.DOTALL) if style_match: extracted_inline_css = style_match.group(1) html_inline_styles_extracted = True if css_template and css_template.strip(): # Use separate CSS template - this is the authoritative source # Don't merge with inline styles - the CSS template should contain everything needed # (Editor saves both HTML with styles AND CSS, but CSS is the clean source) debug_print(f"[DEBUG] Using separate CSS template (length: {len(css_template)})") # Check @page size before update import re before_match = re.search(r"@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)", css_template, re.IGNORECASE | re.DOTALL) if before_match: before_size = before_match.group(1).strip() debug_print(f"[DEBUG] CSS template @page size BEFORE update: '{before_size}'") css_to_use = update_page_size_in_css(css_template, self.page_size) # Update wrapper dimensions to match page size (fixes hardcoded dimension issues) css_to_use = update_wrapper_dimensions_in_css(css_to_use, self.page_size) debug_print(f"[DEBUG] Updated wrapper dimensions in template CSS for page size: {self.page_size}") # Validate @page size after update is_valid, found_sizes = validate_page_size_in_css(css_to_use, self.page_size) if not is_valid: debug_print(f"[ERROR] @page size validation failed! Expected '{self.page_size}', found: {found_sizes}") current_app.logger.warning( f"PDF template CSS @page size mismatch. Expected '{self.page_size}', found: {found_sizes}" ) else: debug_print(f"[DEBUG] ✓ CSS template @page size correctly updated and validated: '{self.page_size}'") elif extracted_inline_css: # Only inline styles exist - extract and use them css_to_use = update_page_size_in_css(extracted_inline_css, self.page_size) css_to_use = update_wrapper_dimensions_in_css(css_to_use, self.page_size) else: # No CSS provided, use default try: from flask import render_template as _render_tpl css_to_use = _render_tpl("invoices/pdf_styles_default.css") css_to_use = update_page_size_in_css(css_to_use, self.page_size) css_to_use = update_wrapper_dimensions_in_css(css_to_use, self.page_size) except Exception: css_to_use = self._generate_css() # Ensure @page rule has correct size - this is critical for PDF generation css = css_to_use # Add comprehensive overflow prevention CSS overflow_css = get_overflow_prevention_css() css = css + "\n" + overflow_css # Import helper functions for template from babel.dates import format_date as babel_format_date from app.utils.template_filters import get_image_base64, get_logo_base64 # Get date format from template, default to %d.%m.%Y date_format_str = ( getattr(self.template, "date_format", "%d.%m.%Y") if hasattr(self, "template") and self.template else "%d.%m.%Y" ) def format_date(value, format="medium"): """Format date for template""" # Use date format from template settings return value.strftime(date_format_str) if value else "" def format_money(value): """Format money for template""" try: return f"{float(value):,.2f}" except Exception: return str(value) # Convert lazy='dynamic' relationships to lists for template rendering # This ensures {% for item in invoice.items %} works correctly try: if hasattr(self.invoice.items, "all"): # It's a SQLAlchemy Query object - need to call .all() invoice_items = self.invoice.items.all() else: # Already a list or other iterable invoice_items = list(self.invoice.items) if self.invoice.items else [] except Exception: invoice_items = [] try: if hasattr(self.invoice.extra_goods, "all"): # It's a SQLAlchemy Query object - need to call .all() invoice_extra_goods = self.invoice.extra_goods.all() else: # Already a list or other iterable invoice_extra_goods = list(self.invoice.extra_goods) if self.invoice.extra_goods else [] except Exception: invoice_extra_goods = [] # Create a wrapper object that has the converted lists from types import SimpleNamespace invoice_data = SimpleNamespace() # Copy all attributes from original invoice for attr in dir(self.invoice): if not attr.startswith("_"): try: setattr(invoice_data, attr, getattr(self.invoice, attr)) except Exception: pass # Override with converted lists invoice_data.items = invoice_items invoice_data.extra_goods = invoice_extra_goods # Convert expenses from Query to list try: if hasattr(self.invoice, "expenses") and hasattr(self.invoice.expenses, "all"): invoice_expenses = self.invoice.expenses.all() else: invoice_expenses = list(self.invoice.expenses) if self.invoice.expenses else [] except Exception: invoice_expenses = [] invoice_data.expenses = invoice_expenses # Load decorative images try: from app.models import InvoiceImage decorative_images = InvoiceImage.get_invoice_images(self.invoice.id) except Exception: decorative_images = [] invoice_data.decorative_images = decorative_images try: # Render using Flask's Jinja environment to include app filters and _() if html_template: from flask import render_template_string # When we have separate CSS, remove @page rules from HTML inline styles # to ensure the separate CSS @page rule is used (WeasyPrint uses first @page it finds) # Keep all other inline styles (like positioning) to preserve layout if html_inline_styles_extracted and css_template: # Check if HTML has @page rules import re html_page_rules = re.findall(r"@page\s*\{[^}]*\}", html_template, re.IGNORECASE | re.DOTALL) if html_page_rules: debug_print( f"[DEBUG] Found {len(html_page_rules)} @page rule(s) in HTML inline styles - removing them" ) for i, rule in enumerate(html_page_rules): debug_print(f"[DEBUG] HTML @page rule {i+1}: {rule[:80]}") # Remove @page rules from HTML inline styles (keep everything else) html_template_updated = remove_page_rule_from_html(html_template) debug_print("[DEBUG] Removed @page rules from HTML inline styles") else: # No separate CSS or no inline styles - use template as-is or update inline @page if html_template and "
| {_('Description')} | {_('Quantity (Hours)')} | {_('Unit Price')} | {_('Total Amount')} |
|---|