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