from __future__ import annotations import os import uuid from datetime import UTC, datetime from pathlib import Path from typing import Any from reportlab.lib import colors from reportlab.lib.pagesizes import letter from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import inch, mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import ( BaseDocTemplate, Frame, Image, NextPageTemplate, PageBreak, PageTemplate, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) from sqlalchemy.orm import Session from app.core.logging_config import get_logger from app.models.template import DocumentFormat from app.schemas.template import TemplateRenderResponse from app.storage.provider import get_storage_provider logger = get_logger(__name__) class ReconstructionService: """Reconstruct documents from templates using ReportLab.""" def __init__(self, db: Session) -> None: self.db = db self.storage = get_storage_provider() self.styles = getSampleStyleSheet() self._register_fonts() def _register_fonts(self) -> None: """Register additional fonts if available.""" # ReportLab includes Helvetica, Times-Roman, Courier by default # Custom fonts can be registered here pass def render_template( self, template: DocumentFormat, data: dict[str, Any], output_filename: str | None = None, images: dict[str, str] | None = None, ) -> TemplateRenderResponse: """Render a template to PDF with supplied data.""" if output_filename is None: output_filename = f"{template.name}_{uuid.uuid4().hex[:8]}.pdf" if not output_filename.endswith(".pdf"): output_filename += ".pdf" # Determine output path output_storage_path = f"rendered/{output_filename}" absolute_output_path = self.storage.get_absolute_path(output_storage_path) # Ensure the rendered directory exists os.makedirs(os.path.dirname(absolute_output_path), exist_ok=True) # Build the PDF self._build_pdf(template, data, absolute_output_path, images) file_size = os.path.getsize(absolute_output_path) logger.info( "template_rendered", template_id=str(template.id), output=output_filename, size=file_size, ) return TemplateRenderResponse( output_path=output_storage_path, filename=output_filename, file_size=file_size, page_count=template.page_count, rendered_at=datetime.now(UTC), ) def _build_pdf( self, template: DocumentFormat, data: dict[str, Any], output_path: str, images: dict[str, str] | None = None, ) -> None: """Build a PDF document from template definition.""" page_width = template.page_width page_height = template.page_height doc = SimpleDocTemplate( output_path, pagesize=(page_width, page_height), topMargin=template.margin_top, rightMargin=template.margin_right, bottomMargin=template.margin_bottom, leftMargin=template.margin_left, ) # Build story (content elements) story: list[Any] = [] for page_num in range(1, template.page_count + 1): if page_num > 1: story.append(PageBreak()) # Add page content page_elements = self._build_page_content(template, page_num, data, images) story.extend(page_elements) # Build with watermark/header/footer callbacks def on_page(canvas, doc_obj): # noqa: ANN001, ANN202 self._draw_watermarks(canvas, template, doc_obj.page) self._draw_headers_footers(canvas, template, doc_obj.page, page_width, page_height) def on_page_later(canvas, doc_obj): # noqa: ANN001, ANN202 self._draw_watermarks(canvas, template, doc_obj.page) self._draw_headers_footers(canvas, template, doc_obj.page, page_width, page_height) doc.build(story, onFirstPage=on_page, onLaterPages=on_page_later) def _build_page_content( self, template: DocumentFormat, page_number: int, data: dict[str, Any], images: dict[str, str] | None = None, ) -> list[Any]: """Build content elements for a specific page.""" elements: list[Any] = [] # Get cells for this page, sorted by sequence page_cells = sorted( [c for c in template.cells if c.page_number == page_number], key=lambda c: c.sequence, ) # Get table formats for this page page_tables = [t for t in template.table_formats if t.page_number == page_number] # Get image regions for this page page_images = [i for i in template.image_regions if i.page_number == page_number] # Add static and dynamic text cells for cell in page_cells: text = self._resolve_cell_text(cell, data) if text: style = self._create_cell_style(cell) para = Paragraph(text, style) elements.append(para) elements.append(Spacer(1, 2)) # Add tables for table_format in page_tables: table_element = self._build_table(table_format, data) if table_element: elements.append(table_element) elements.append(Spacer(1, 6)) # Add images for img_region in page_images: img_element = self._build_image(img_region, images) if img_element: elements.append(img_element) elements.append(Spacer(1, 6)) if not elements: elements.append(Spacer(1, 12)) return elements def _resolve_cell_text(self, cell: Any, data: dict[str, Any]) -> str: """Resolve cell text from static content or dynamic data.""" if cell.is_dynamic and cell.field_name: value = data.get(cell.field_name, "") return str(value) if value else "" return cell.static_text or "" def _create_cell_style(self, cell: Any) -> ParagraphStyle: """Create a ReportLab paragraph style from cell properties.""" font_name = "Helvetica" if cell.font_family: family = cell.font_family.lower() if "times" in family or "serif" in family: font_name = "Times-Roman" elif "courier" in family or "mono" in family: font_name = "Courier" font_size = cell.font_size or 10 if cell.font_style and "bold" in (cell.font_style or ""): if font_name == "Helvetica": font_name = "Helvetica-Bold" elif font_name == "Times-Roman": font_name = "Times-Bold" elif font_name == "Courier": font_name = "Courier-Bold" text_color = colors.black if cell.font_color: try: text_color = colors.HexColor(cell.font_color) except (ValueError, TypeError): text_color = colors.black alignment_map = {"left": 0, "center": 1, "right": 2, "justify": 4} alignment = alignment_map.get(cell.alignment, 0) style = ParagraphStyle( name=f"cell_{cell.id}", parent=self.styles["Normal"], fontName=font_name, fontSize=font_size, textColor=text_color, alignment=alignment, leading=font_size * 1.2, spaceBefore=cell.padding_top, spaceAfter=cell.padding_bottom, leftIndent=cell.padding_left, rightIndent=cell.padding_right, ) return style def _build_table(self, table_format: Any, data: dict[str, Any]) -> Table | None: """Build a ReportLab table from a table format definition.""" rows = table_format.rows columns = table_format.columns if rows <= 0 or columns <= 0: return None # Build table data table_data: list[list[str]] = [] # Header row if table_format.table_columns: header_row = [col.header_text or f"Col {col.column_index + 1}" for col in table_format.table_columns] table_data.append(header_row) else: table_data.append([f"Column {i + 1}" for i in range(columns)]) # Data rows from supplied data table_field_name = f"table_{table_format.id}" table_rows_data = data.get(table_field_name, data.get("table_data", [])) if isinstance(table_rows_data, list): for row_data in table_rows_data: if isinstance(row_data, list): # Pad or trim to match column count row = row_data[:columns] while len(row) < columns: row.append("") table_data.append([str(v) for v in row]) elif isinstance(row_data, dict): row = [] for col in table_format.table_columns: key = col.header_text or f"col_{col.column_index}" row.append(str(row_data.get(key, ""))) table_data.append(row) # If no data rows, add empty rows if len(table_data) <= 1: for _ in range(max(rows - 1, 1)): table_data.append([""] * columns) # Determine column widths col_widths = [] if table_format.table_columns: col_widths = [col.width for col in table_format.table_columns] else: col_width = table_format.width / columns col_widths = [col_width] * columns # Build table table = Table(table_data, colWidths=col_widths) # Apply table style border_color = colors.black if table_format.border_color: try: border_color = colors.HexColor(table_format.border_color) except (ValueError, TypeError): pass style_commands = [ ("GRID", (0, 0), (-1, -1), table_format.border_width, border_color), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 9), ("ALIGN", (0, 0), (-1, -1), "LEFT"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4), ] # Header row background if table_format.table_rows: for row in table_format.table_rows: if row.is_header and row.background_color: try: bg_color = colors.HexColor(row.background_color) style_commands.append( ("BACKGROUND", (0, row.row_index), (-1, row.row_index), bg_color) ) except (ValueError, TypeError): pass else: style_commands.append(("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E0E0E0"))) table.setStyle(TableStyle(style_commands)) return table def _build_image(self, img_region: Any, images: dict[str, str] | None = None) -> Image | None: """Build a ReportLab image from an image region definition.""" image_path = None # Check dynamic images first if not img_region.is_static and img_region.field_name and images: image_path = images.get(img_region.field_name) # Fall back to stored image if not image_path and img_region.image_path: try: image_path = self.storage.get_absolute_path(img_region.image_path) except Exception: image_path = None if not image_path or not os.path.exists(image_path): return None try: img = Image(image_path, width=img_region.width, height=img_region.height) return img except Exception as e: logger.warning("image_build_failed", error=str(e), path=image_path) return None def _draw_watermarks(self, canvas: Any, template: DocumentFormat, current_page: int) -> None: """Draw watermarks on the canvas.""" for watermark in template.watermarks: # Apply to all pages if page_number is None, or specific page if watermark.page_number is not None and watermark.page_number != current_page: continue canvas.saveState() # Set opacity canvas.setFillAlpha(watermark.opacity) if watermark.text: # Text watermark font_name = "Helvetica" if watermark.font_family: family = watermark.font_family.lower() if "times" in family: font_name = "Times-Roman" elif "courier" in family: font_name = "Courier" font_size = watermark.font_size or 48 if watermark.font_color: try: canvas.setFillColor(colors.HexColor(watermark.font_color)) except (ValueError, TypeError): canvas.setFillColor(colors.grey) else: canvas.setFillColor(colors.grey) canvas.setFont(font_name, font_size) # Position and rotate canvas.translate( watermark.x + watermark.width / 2, watermark.y + watermark.height / 2, ) canvas.rotate(watermark.rotation) canvas.drawCentredString(0, 0, watermark.text) elif watermark.image_path: # Image watermark try: img_path = self.storage.get_absolute_path(watermark.image_path) if os.path.exists(img_path): canvas.drawImage( img_path, watermark.x, watermark.y, width=watermark.width, height=watermark.height, mask="auto", ) except Exception as e: logger.warning("watermark_image_failed", error=str(e)) canvas.restoreState() def _draw_headers_footers( self, canvas: Any, template: DocumentFormat, current_page: int, page_width: float, page_height: float, ) -> None: """Draw header and footer regions on the canvas.""" for region in template.regions: if region.page_number != current_page: continue content = region.content or {} blocks = content.get("blocks", []) canvas.saveState() for block in blocks: text = block.get("text", "") if not text: continue x = block.get("x", region.x) y = page_height - block.get("y", region.y) - block.get("height", 12) font_family = block.get("font_family", "Helvetica") font_size = block.get("font_size", 10) # Map font family font_name = "Helvetica" if font_family: fl = font_family.lower() if "times" in fl or "serif" in fl: font_name = "Times-Roman" elif "courier" in fl or "mono" in fl: font_name = "Courier" canvas.setFont(font_name, font_size) canvas.setFillColor(colors.black) canvas.drawString(x, y, text) canvas.restoreState()