Files
OCR/docengine/app/services/reconstruction_service.py
2026-06-01 21:49:53 +05:30

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