Changes committed

This commit is contained in:
2026-06-01 21:49:53 +05:30
parent 8537c653c1
commit 3163bb213e
387 changed files with 21940 additions and 107 deletions

View File

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import uuid
from sqlalchemy.orm import Session
from app.core.logging_config import get_logger
from app.models.document import Document
from app.repositories.document_repository import DocumentRepository
from app.services.layout_service import LayoutService
from app.services.ocr_service import OCRService
from app.services.pdf_service import NativePDFService
from app.services.template_service import TemplateService
logger = get_logger(__name__)
class DocumentProcessingService:
"""Orchestrates the full document processing pipeline."""
def __init__(self, db: Session) -> None:
self.db = db
self.doc_repo = DocumentRepository(db)
self.pdf_service = NativePDFService(db)
self.ocr_service = OCRService(db)
self.layout_service = LayoutService(db)
self.template_service = TemplateService(db)
def process_document(self, document_id: str | uuid.UUID) -> Document:
"""Process a document through the full pipeline."""
if isinstance(document_id, str):
document_id = uuid.UUID(document_id)
document = self.doc_repo.get_by_id(document_id)
if not document:
raise ValueError(f"Document '{document_id}' not found")
logger.info(
"processing_started",
document_id=str(document_id),
content_type=document.content_type,
)
# Update status to processing
self.doc_repo.update_status(document_id, "processing")
self.db.commit()
try:
# Step 1: Extract content based on document type
if document.content_type == "application/pdf":
document = self._process_pdf(document)
else:
document = self._process_image(document)
# Step 2: Analyze layout
layout_results = self.layout_service.analyze_document_layout(document)
document.document_metadata = document.document_metadata or {}
document.document_metadata["layout"] = layout_results
# Step 3: Generate template
template = self.template_service.generate_template(document)
# Step 4: Update document status
self.doc_repo.update_status(document_id, "completed")
self.db.commit()
logger.info(
"processing_completed",
document_id=str(document_id),
pages=document.page_count,
template_id=str(template.id),
)
return document
except Exception as e:
logger.exception(
"processing_failed",
document_id=str(document_id),
error=str(e),
)
self.doc_repo.update_status(document_id, "failed", error_message=str(e))
self.db.commit()
raise
def _process_pdf(self, document: Document) -> Document:
"""Process a PDF document - either native or scanned."""
# First, try native PDF extraction
document = self.pdf_service.process_pdf(document)
self.db.flush()
# If scanned, also run OCR
if document.is_scanned:
logger.info(
"scanned_pdf_detected",
document_id=str(document.id),
)
document = self.ocr_service.process_scanned_pdf(document)
self.db.flush()
return document
def _process_image(self, document: Document) -> Document:
"""Process an image document with OCR."""
document = self.ocr_service.process_image(document)
self.db.flush()
return document

View File

@@ -0,0 +1,343 @@
from __future__ import annotations
import hashlib
import json
import uuid
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging_config import get_logger
from app.models.template import DocumentFormat, TemplateFingerprint
from app.repositories.template_repository import TemplateFingerprintRepository, TemplateRepository
logger = get_logger(__name__)
class FingerprintService:
"""Generate and manage layout fingerprints for template matching."""
def __init__(self, db: Session) -> None:
self.db = db
self.fingerprint_repo = TemplateFingerprintRepository(db)
self.template_repo = TemplateRepository(db)
def generate_fingerprint(self, template: DocumentFormat) -> TemplateFingerprint:
"""Generate a layout fingerprint for a template."""
# Collect page dimensions
page_dimensions = {
"width": template.page_width,
"height": template.page_height,
"page_count": template.page_count,
"margins": {
"top": template.margin_top,
"right": template.margin_right,
"bottom": template.margin_bottom,
"left": template.margin_left,
},
}
# Collect logo coordinates
logo_coordinates = self._extract_logo_coordinates(template)
# Collect header coordinates
header_coordinates = self._extract_region_coordinates(template, "header")
# Collect footer coordinates
footer_coordinates = self._extract_region_coordinates(template, "footer")
# Collect table coordinates
table_coordinates = self._extract_table_coordinates(template)
# Collect cell coordinates
cell_coordinates = self._extract_cell_coordinates(template)
# Compute fingerprint hash
fingerprint_data = {
"page_dimensions": page_dimensions,
"logo_coordinates": logo_coordinates,
"header_coordinates": header_coordinates,
"footer_coordinates": footer_coordinates,
"table_coordinates": table_coordinates,
"cell_coordinates": cell_coordinates,
}
fingerprint_hash = self._compute_hash(fingerprint_data)
# Check for existing fingerprint
existing = self.fingerprint_repo.get_by_format_id(template.id)
if existing:
# Update existing
existing.page_dimensions = page_dimensions
existing.logo_coordinates = logo_coordinates
existing.header_coordinates = header_coordinates
existing.footer_coordinates = footer_coordinates
existing.table_coordinates = table_coordinates
existing.cell_coordinates = cell_coordinates
existing.fingerprint_hash = fingerprint_hash
self.db.flush()
self.db.refresh(existing)
return existing
# Create new fingerprint
fingerprint = self.fingerprint_repo.create_fingerprint(
format_id=template.id,
fingerprint_hash=fingerprint_hash,
page_dimensions=page_dimensions,
logo_coordinates=logo_coordinates,
header_coordinates=header_coordinates,
footer_coordinates=footer_coordinates,
table_coordinates=table_coordinates,
cell_coordinates=cell_coordinates,
)
logger.info(
"fingerprint_generated",
template_id=str(template.id),
hash=fingerprint_hash[:16],
)
return fingerprint
def _extract_logo_coordinates(self, template: DocumentFormat) -> dict[str, Any] | None:
"""Extract logo image coordinates from template."""
logos = [ir for ir in template.image_regions if ir.image_type == "logo"]
if not logos:
return None
return {
"items": [
{
"page": ir.page_number,
"x": ir.x,
"y": ir.y,
"width": ir.width,
"height": ir.height,
}
for ir in logos
]
}
def _extract_region_coordinates(
self,
template: DocumentFormat,
region_type: str,
) -> dict[str, Any] | None:
"""Extract coordinates for a specific region type."""
regions = [r for r in template.regions if r.region_type == region_type]
if not regions:
return None
return {
"items": [
{
"page": r.page_number,
"x": r.x,
"y": r.y,
"width": r.width,
"height": r.height,
}
for r in regions
]
}
def _extract_table_coordinates(self, template: DocumentFormat) -> dict[str, Any] | None:
"""Extract table coordinates from template."""
if not template.table_formats:
return None
return {
"items": [
{
"page": tf.page_number,
"x": tf.x,
"y": tf.y,
"width": tf.width,
"height": tf.height,
"rows": tf.rows,
"columns": tf.columns,
}
for tf in template.table_formats
]
}
def _extract_cell_coordinates(self, template: DocumentFormat) -> dict[str, Any] | None:
"""Extract cell coordinates from template."""
if not template.cells:
return None
return {
"items": [
{
"page": c.page_number,
"x": c.x,
"y": c.y,
"width": c.width,
"height": c.height,
"row": c.row_no,
"col": c.column_no,
}
for c in template.cells
]
}
def _compute_hash(self, data: dict[str, Any]) -> str:
"""Compute a deterministic hash of the fingerprint data."""
# Normalize coordinates to reduce sensitivity to minor variations
normalized = self._normalize_coordinates(data)
serialized = json.dumps(normalized, sort_keys=True, default=str)
return hashlib.sha256(serialized.encode()).hexdigest()
def _normalize_coordinates(self, data: dict[str, Any]) -> dict[str, Any]:
"""Normalize coordinates by rounding to reduce sensitivity to small variations."""
if isinstance(data, dict):
return {k: self._normalize_coordinates(v) for k, v in data.items()}
elif isinstance(data, list):
return [self._normalize_coordinates(item) for item in data]
elif isinstance(data, float):
return round(data, 1)
return data
def compute_similarity(
self,
fingerprint1: TemplateFingerprint,
fingerprint2_data: dict[str, Any],
) -> float:
"""Compute similarity score between a stored fingerprint and new document data."""
scores: list[float] = []
weights: list[float] = []
# Page dimensions similarity (high weight)
dim_score = self._compare_dimensions(
fingerprint1.page_dimensions,
fingerprint2_data.get("page_dimensions"),
)
scores.append(dim_score)
weights.append(3.0)
# Logo coordinates similarity
logo_score = self._compare_coordinates(
fingerprint1.logo_coordinates,
fingerprint2_data.get("logo_coordinates"),
)
scores.append(logo_score)
weights.append(2.0)
# Header coordinates similarity
header_score = self._compare_coordinates(
fingerprint1.header_coordinates,
fingerprint2_data.get("header_coordinates"),
)
scores.append(header_score)
weights.append(2.0)
# Footer coordinates similarity
footer_score = self._compare_coordinates(
fingerprint1.footer_coordinates,
fingerprint2_data.get("footer_coordinates"),
)
scores.append(footer_score)
weights.append(1.5)
# Table coordinates similarity
table_score = self._compare_coordinates(
fingerprint1.table_coordinates,
fingerprint2_data.get("table_coordinates"),
)
scores.append(table_score)
weights.append(2.5)
# Cell coordinates similarity
cell_score = self._compare_coordinates(
fingerprint1.cell_coordinates,
fingerprint2_data.get("cell_coordinates"),
)
scores.append(cell_score)
weights.append(1.5)
# Weighted average
total_weight = sum(weights)
if total_weight == 0:
return 0.0
weighted_sum = sum(s * w for s, w in zip(scores, weights))
return weighted_sum / total_weight
def _compare_dimensions(
self,
dims1: dict[str, Any] | None,
dims2: dict[str, Any] | None,
) -> float:
"""Compare page dimensions similarity."""
if not dims1 or not dims2:
return 0.0 if (dims1 or dims2) else 1.0
width_ratio = min(dims1.get("width", 0), dims2.get("width", 0)) / max(
dims1.get("width", 1), dims2.get("width", 1)
)
height_ratio = min(dims1.get("height", 0), dims2.get("height", 0)) / max(
dims1.get("height", 1), dims2.get("height", 1)
)
page_count_match = 1.0 if dims1.get("page_count") == dims2.get("page_count") else 0.5
return (width_ratio + height_ratio + page_count_match) / 3.0
def _compare_coordinates(
self,
coords1: dict[str, Any] | None,
coords2: dict[str, Any] | None,
) -> float:
"""Compare coordinate sets for similarity."""
if not coords1 and not coords2:
return 1.0
if not coords1 or not coords2:
return 0.0
items1 = coords1.get("items", [])
items2 = coords2.get("items", [])
if not items1 and not items2:
return 1.0
if not items1 or not items2:
return 0.0
# Compare number of items
count_ratio = min(len(items1), len(items2)) / max(len(items1), len(items2))
# Compare positions of matched items
position_scores = []
for item1 in items1:
best_match = 0.0
for item2 in items2:
if item1.get("page") != item2.get("page"):
continue
score = self._compute_bbox_iou(item1, item2)
best_match = max(best_match, score)
position_scores.append(best_match)
avg_position_score = sum(position_scores) / len(position_scores) if position_scores else 0.0
return (count_ratio + avg_position_score) / 2.0
def _compute_bbox_iou(self, bbox1: dict[str, Any], bbox2: dict[str, Any]) -> float:
"""Compute Intersection over Union for two bounding boxes."""
x1 = max(bbox1.get("x", 0), bbox2.get("x", 0))
y1 = max(bbox1.get("y", 0), bbox2.get("y", 0))
x2 = min(
bbox1.get("x", 0) + bbox1.get("width", 0),
bbox2.get("x", 0) + bbox2.get("width", 0),
)
y2 = min(
bbox1.get("y", 0) + bbox1.get("height", 0),
bbox2.get("y", 0) + bbox2.get("height", 0),
)
intersection = max(0, x2 - x1) * max(0, y2 - y1)
area1 = bbox1.get("width", 0) * bbox1.get("height", 0)
area2 = bbox2.get("width", 0) * bbox2.get("height", 0)
union = area1 + area2 - intersection
if union == 0:
return 0.0
return intersection / union

View File

@@ -0,0 +1,317 @@
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from sqlalchemy.orm import Session
from app.core.logging_config import get_logger
from app.models.document import Document, DocumentPage
from app.repositories.document_repository import (
DocumentPageRepository,
DocumentTableRepository,
DocumentTextBlockRepository,
)
from app.storage.provider import get_storage_provider
logger = get_logger(__name__)
class LayoutService:
"""Document layout analysis service using OpenCV-based detection."""
def __init__(self, db: Session) -> None:
self.db = db
self.storage = get_storage_provider()
self.page_repo = DocumentPageRepository(db)
self.text_block_repo = DocumentTextBlockRepository(db)
self.table_repo = DocumentTableRepository(db)
def analyze_document_layout(self, document: Document) -> dict[str, Any]:
"""Analyze the layout of all pages in a document."""
layout_results: dict[str, Any] = {"pages": []}
for page in document.pages:
page_layout = self._analyze_page_layout(page)
layout_results["pages"].append(page_layout)
return layout_results
def _analyze_page_layout(self, page: DocumentPage) -> dict[str, Any]:
"""Analyze layout of a single page."""
result: dict[str, Any] = {
"page_number": page.page_number,
"width": page.width,
"height": page.height,
"tables": [],
"lines": [],
"rectangles": [],
"text_regions": [],
"image_regions": [],
}
if not page.image_path:
return result
image_path = self.storage.get_absolute_path(page.image_path)
image = cv2.imread(image_path)
if image is None:
logger.warning("layout_image_read_failed", page_id=str(page.id))
return result
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Detect lines
result["lines"] = self._detect_lines(gray)
# Detect rectangles (potential table cells/borders)
result["rectangles"] = self._detect_rectangles(gray)
# Detect tables
tables = self._detect_tables(gray, image.shape)
result["tables"] = tables
# Store detected tables in the database
for table_data in tables:
self.table_repo.create_table(
page_id=page.id,
x=table_data["x"],
y=table_data["y"],
width=table_data["width"],
height=table_data["height"],
rows=table_data["rows"],
columns=table_data["columns"],
data=table_data.get("cells"),
)
# Detect watermarks
watermark = self._detect_watermark(gray, image.shape)
if watermark:
result["watermark"] = watermark
return result
def _detect_lines(self, gray: np.ndarray) -> list[dict[str, Any]]:
"""Detect horizontal and vertical lines in the image."""
lines_detected: list[dict[str, Any]] = []
# Apply edge detection
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
# Detect lines using Hough transform
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100, minLineLength=50, maxLineGap=10)
if lines is not None:
for line in lines:
x1, y1, x2, y2 = line[0]
length = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
# Classify as horizontal or vertical
angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
if abs(angle) < 5 or abs(angle - 180) < 5:
orientation = "horizontal"
elif abs(angle - 90) < 5 or abs(angle + 90) < 5:
orientation = "vertical"
else:
orientation = "diagonal"
lines_detected.append({
"x1": float(x1),
"y1": float(y1),
"x2": float(x2),
"y2": float(y2),
"length": float(length),
"orientation": orientation,
})
return lines_detected
def _detect_rectangles(self, gray: np.ndarray) -> list[dict[str, Any]]:
"""Detect rectangular regions in the image."""
rectangles: list[dict[str, Any]] = []
# Binary threshold
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
# Find contours
contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
# Approximate the contour
peri = cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, 0.02 * peri, True)
# If approximation has 4 vertices, it's likely a rectangle
if len(approx) == 4:
x, y, w, h = cv2.boundingRect(approx)
# Filter out very small or very large rectangles
area = w * h
if area > 500 and w > 10 and h > 10:
rectangles.append({
"x": float(x),
"y": float(y),
"width": float(w),
"height": float(h),
"area": float(area),
})
return rectangles
def _detect_tables(self, gray: np.ndarray, image_shape: tuple) -> list[dict[str, Any]]:
"""Detect table structures using morphological operations."""
tables: list[dict[str, Any]] = []
h, w = image_shape[:2]
# Binary threshold
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
# Detect horizontal lines
horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(w // 30, 1), 1))
horizontal = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horizontal_kernel, iterations=2)
# Detect vertical lines
vertical_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(h // 30, 1)))
vertical = cv2.morphologyEx(binary, cv2.MORPH_OPEN, vertical_kernel, iterations=2)
# Combine horizontal and vertical lines to find intersections
table_mask = cv2.add(horizontal, vertical)
# Find contours of table regions
contours, _ = cv2.findContours(table_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
x, y, cw, ch = cv2.boundingRect(contour)
area = cw * ch
# Filter: table should be reasonably sized
if area < 5000 or cw < 50 or ch < 30:
continue
# Estimate rows and columns
rows, columns = self._estimate_table_dimensions(
table_mask[y:y+ch, x:x+cw], cw, ch
)
if rows >= 1 and columns >= 1:
# Extract cell contents
cells = self._extract_table_cells(
gray[y:y+ch, x:x+cw], rows, columns, cw, ch
)
tables.append({
"x": float(x),
"y": float(y),
"width": float(cw),
"height": float(ch),
"rows": rows,
"columns": columns,
"cells": cells,
})
return tables
def _estimate_table_dimensions(
self,
table_region: np.ndarray,
width: int,
height: int,
) -> tuple[int, int]:
"""Estimate the number of rows and columns in a table region."""
# Project horizontal lines
h_projection = np.sum(table_region, axis=1)
h_peaks = self._count_peaks(h_projection, height)
# Project vertical lines
v_projection = np.sum(table_region, axis=0)
v_peaks = self._count_peaks(v_projection, width)
rows = max(1, h_peaks - 1)
columns = max(1, v_peaks - 1)
return rows, columns
def _count_peaks(self, projection: np.ndarray, total_length: int) -> int:
"""Count significant peaks in a projection array."""
if len(projection) == 0:
return 0
threshold = np.max(projection) * 0.3
above_threshold = projection > threshold
# Count transitions from below to above threshold
peaks = 0
in_peak = False
for val in above_threshold:
if val and not in_peak:
peaks += 1
in_peak = True
elif not val:
in_peak = False
return peaks
def _extract_table_cells(
self,
table_gray: np.ndarray,
rows: int,
columns: int,
width: int,
height: int,
) -> dict[str, Any]:
"""Extract cell structure data from a table region."""
cell_height = height / max(rows, 1)
cell_width = width / max(columns, 1)
cells: dict[str, Any] = {"rows": rows, "columns": columns, "data": []}
for r in range(rows):
row_data = []
for c in range(columns):
cell_x = int(c * cell_width)
cell_y = int(r * cell_height)
cell_w = int(cell_width)
cell_h = int(cell_height)
row_data.append({
"row": r,
"col": c,
"x": cell_x,
"y": cell_y,
"width": cell_w,
"height": cell_h,
})
cells["data"].append(row_data)
return cells
def _detect_watermark(self, gray: np.ndarray, image_shape: tuple) -> dict[str, Any] | None:
"""Detect potential watermark regions."""
h, w = image_shape[:2]
# Look for semi-transparent or light text in the center region
center_region = gray[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4]
# Apply adaptive threshold to find light text
_, binary = cv2.threshold(center_region, 230, 255, cv2.THRESH_BINARY)
# Find contours in the center region
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
x, y, cw, ch = cv2.boundingRect(contour)
area = cw * ch
# Watermark typically covers a significant portion of the center
center_area = (w // 2) * (h // 2)
if area > center_area * 0.1:
return {
"x": float(x + w // 4),
"y": float(y + h // 4),
"width": float(cw),
"height": float(ch),
"detected": True,
}
return None

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging_config import get_logger
from app.models.document import Document, TemplateMatch
from app.models.template import DocumentFormat
from app.repositories.document_repository import DocumentRepository, TemplateMatchRepository
from app.repositories.template_repository import TemplateFingerprintRepository, TemplateRepository
from app.services.fingerprint_service import FingerprintService
logger = get_logger(__name__)
class MatchingService:
"""Match documents against existing templates using fingerprint comparison."""
def __init__(self, db: Session) -> None:
self.db = db
self.doc_repo = DocumentRepository(db)
self.template_repo = TemplateRepository(db)
self.match_repo = TemplateMatchRepository(db)
self.fingerprint_repo = TemplateFingerprintRepository(db)
self.fingerprint_service = FingerprintService(db)
def match_document(
self,
document_id: uuid.UUID,
min_confidence: float = 0.5,
max_results: int = 5,
) -> list[TemplateMatch]:
"""Match a document against all existing templates."""
document = self.doc_repo.get_with_pages(document_id)
if not document:
raise ValueError(f"Document '{document_id}' not found")
if not document.pages:
raise ValueError(f"Document '{document_id}' has no processed pages")
# Generate document fingerprint data
doc_fingerprint_data = self._build_document_fingerprint(document)
# Get all templates with fingerprints
templates = self.template_repo.get_all_with_fingerprints()
fingerprints = self.fingerprint_repo.get_all_fingerprints()
# Map format_id -> fingerprint
fp_map = {fp.format_id: fp for fp in fingerprints}
matches: list[tuple[DocumentFormat, float, dict[str, Any]]] = []
for template in templates:
fp = fp_map.get(template.id)
if not fp:
continue
score = self.fingerprint_service.compute_similarity(fp, doc_fingerprint_data)
if score >= min_confidence:
match_details = {
"page_dimensions_score": self.fingerprint_service._compare_dimensions(
fp.page_dimensions, doc_fingerprint_data.get("page_dimensions")
),
"logo_score": self.fingerprint_service._compare_coordinates(
fp.logo_coordinates, doc_fingerprint_data.get("logo_coordinates")
),
"header_score": self.fingerprint_service._compare_coordinates(
fp.header_coordinates, doc_fingerprint_data.get("header_coordinates")
),
"footer_score": self.fingerprint_service._compare_coordinates(
fp.footer_coordinates, doc_fingerprint_data.get("footer_coordinates")
),
"table_score": self.fingerprint_service._compare_coordinates(
fp.table_coordinates, doc_fingerprint_data.get("table_coordinates")
),
"cell_score": self.fingerprint_service._compare_coordinates(
fp.cell_coordinates, doc_fingerprint_data.get("cell_coordinates")
),
}
matches.append((template, score, match_details))
# Sort by score descending
matches.sort(key=lambda x: x[1], reverse=True)
matches = matches[:max_results]
# Store match results
result_matches: list[TemplateMatch] = []
for idx, (template, score, details) in enumerate(matches):
template_match = self.match_repo.create_match(
document_id=document_id,
format_id=template.id,
confidence_score=score,
match_details=details,
selected=(idx == 0), # Auto-select best match
)
result_matches.append(template_match)
logger.info(
"document_matched",
document_id=str(document_id),
matches_found=len(result_matches),
best_score=result_matches[0].confidence_score if result_matches else 0.0,
)
return result_matches
def _build_document_fingerprint(self, document: Document) -> dict[str, Any]:
"""Build fingerprint data from a document for comparison."""
first_page = document.pages[0] if document.pages else None
page_dimensions = None
if first_page:
page_dimensions = {
"width": first_page.width,
"height": first_page.height,
"page_count": document.page_count or len(document.pages),
}
# Extract logo coordinates from images
logo_coordinates = None
logos = []
for page in document.pages:
for img in page.images:
if img.image_type == "logo":
logos.append({
"page": page.page_number,
"x": img.x,
"y": img.y,
"width": img.width,
"height": img.height,
})
if logos:
logo_coordinates = {"items": logos}
# Extract header coordinates
header_coordinates = None
headers = []
for page in document.pages:
header_blocks = [b for b in page.text_blocks if b.block_type == "header"]
if header_blocks:
min_x = min(b.x for b in header_blocks)
min_y = min(b.y for b in header_blocks)
max_x = max(b.x + b.width for b in header_blocks)
max_y = max(b.y + b.height for b in header_blocks)
headers.append({
"page": page.page_number,
"x": min_x,
"y": min_y,
"width": max_x - min_x,
"height": max_y - min_y,
})
if headers:
header_coordinates = {"items": headers}
# Extract footer coordinates
footer_coordinates = None
footers = []
for page in document.pages:
footer_blocks = [b for b in page.text_blocks if b.block_type == "footer"]
if footer_blocks:
min_x = min(b.x for b in footer_blocks)
min_y = min(b.y for b in footer_blocks)
max_x = max(b.x + b.width for b in footer_blocks)
max_y = max(b.y + b.height for b in footer_blocks)
footers.append({
"page": page.page_number,
"x": min_x,
"y": min_y,
"width": max_x - min_x,
"height": max_y - min_y,
})
if footers:
footer_coordinates = {"items": footers}
# Extract table coordinates
table_coordinates = None
tables = []
for page in document.pages:
for table in page.tables:
tables.append({
"page": page.page_number,
"x": table.x,
"y": table.y,
"width": table.width,
"height": table.height,
"rows": table.rows,
"columns": table.columns,
})
if tables:
table_coordinates = {"items": tables}
# Extract cell coordinates from text blocks
cell_coordinates = None
cells = []
for page in document.pages:
for block in page.text_blocks:
if block.block_type == "text":
cells.append({
"page": page.page_number,
"x": block.x,
"y": block.y,
"width": block.width,
"height": block.height,
})
if cells:
cell_coordinates = {"items": cells}
return {
"page_dimensions": page_dimensions,
"logo_coordinates": logo_coordinates,
"header_coordinates": header_coordinates,
"footer_coordinates": footer_coordinates,
"table_coordinates": table_coordinates,
"cell_coordinates": cell_coordinates,
}

View File

@@ -0,0 +1,210 @@
from __future__ import annotations
import uuid
from pathlib import Path
import cv2
import numpy as np
from paddleocr import PaddleOCR
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logging_config import get_logger
from app.models.document import Document, DocumentPage
from app.repositories.document_repository import (
DocumentPageRepository,
DocumentRepository,
DocumentTextBlockRepository,
)
from app.storage.provider import get_storage_provider
logger = get_logger(__name__)
_ocr_instance: PaddleOCR | None = None
def get_ocr_engine() -> PaddleOCR:
"""Get or create singleton PaddleOCR instance."""
global _ocr_instance
if _ocr_instance is None:
_ocr_instance = PaddleOCR(
use_angle_cls=True,
lang=settings.ocr_language,
use_gpu=settings.ocr_use_gpu,
show_log=False,
det_db_thresh=0.3,
det_db_box_thresh=0.5,
rec_batch_num=6,
)
return _ocr_instance
class OCRService:
"""OCR processing service using PaddleOCR for scanned documents."""
def __init__(self, db: Session) -> None:
self.db = db
self.storage = get_storage_provider()
self.doc_repo = DocumentRepository(db)
self.page_repo = DocumentPageRepository(db)
self.text_block_repo = DocumentTextBlockRepository(db)
def process_image(self, document: Document) -> Document:
"""Process a scanned image document with OCR."""
file_path = self.storage.get_absolute_path(document.storage_path)
image = cv2.imread(file_path)
if image is None:
raise ValueError(f"Failed to read image: {file_path}")
height, width = image.shape[:2]
document.page_count = 1
document.is_scanned = True
# Save page image
image_filename = f"{document.id}_page_1.png"
image_bytes = cv2.imencode(".png", image)[1].tobytes()
image_path = self.storage.save_file(image_bytes, "images", image_filename)
# Create page record
doc_page = self.page_repo.create_page(
document_id=document.id,
page_number=1,
width=float(width),
height=float(height),
image_path=image_path,
)
# Run OCR
self._run_ocr_on_page(doc_page, file_path)
return document
def process_scanned_pdf(self, document: Document) -> Document:
"""Process a scanned PDF document - convert pages to images and OCR each."""
file_path = self.storage.get_absolute_path(document.storage_path)
try:
from pdf2image import convert_from_path
images = convert_from_path(file_path, dpi=300)
except Exception as e:
logger.error("pdf_to_image_failed", document_id=str(document.id), error=str(e))
raise
document.page_count = len(images)
document.is_scanned = True
for page_num, pil_image in enumerate(images, start=1):
# Convert PIL to OpenCV format
np_image = np.array(pil_image)
cv_image = cv2.cvtColor(np_image, cv2.COLOR_RGB2BGR)
height, width = cv_image.shape[:2]
# Save page image
image_filename = f"{document.id}_page_{page_num}.png"
image_bytes = cv2.imencode(".png", cv_image)[1].tobytes()
image_path = self.storage.save_file(image_bytes, "images", image_filename)
# Create page record
doc_page = self.page_repo.create_page(
document_id=document.id,
page_number=page_num,
width=float(width),
height=float(height),
image_path=image_path,
)
# Run OCR on saved image
temp_path = self.storage.get_absolute_path(image_path)
self._run_ocr_on_page(doc_page, temp_path)
return document
def _run_ocr_on_page(self, doc_page: DocumentPage, image_path: str) -> None:
"""Run PaddleOCR on a single page image and store results."""
ocr = get_ocr_engine()
try:
results = ocr.ocr(image_path, cls=True)
except Exception as e:
logger.error("ocr_failed", page_id=str(doc_page.id), error=str(e))
return
if not results or not results[0]:
logger.info("ocr_no_results", page_id=str(doc_page.id))
return
full_text_parts = []
sequence = 0
for line in results[0]:
if not line or len(line) < 2:
continue
bbox_points = line[0] # List of 4 corner points
text_info = line[1] # (text, confidence)
text = text_info[0] if isinstance(text_info, (list, tuple)) else str(text_info)
confidence = float(text_info[1]) if isinstance(text_info, (list, tuple)) and len(text_info) > 1 else 0.0
if not text.strip():
continue
# Convert bbox points to x, y, width, height
xs = [p[0] for p in bbox_points]
ys = [p[1] for p in bbox_points]
x = min(xs)
y = min(ys)
width = max(xs) - x
height = max(ys) - y
# Determine block type based on position
block_type = self._classify_text_block(
y, height, doc_page.height, text
)
self.text_block_repo.create_text_block(
page_id=doc_page.id,
text=text,
x=x,
y=y,
width=width,
height=height,
confidence=confidence,
block_type=block_type,
sequence=sequence,
)
full_text_parts.append(text)
sequence += 1
# Update page text content
doc_page.text_content = "\n".join(full_text_parts)
def _classify_text_block(
self,
y: float,
height: float,
page_height: float,
text: str,
) -> str:
"""Classify a text block as header, footer, watermark, or regular text."""
if page_height <= 0:
return "text"
relative_y = y / page_height
# Header: top 10%
if relative_y < 0.10:
return "header"
# Footer: bottom 10%
if relative_y > 0.90:
return "footer"
# Watermark detection heuristic: large text in center
if 0.3 < relative_y < 0.7 and height > page_height * 0.05:
# Check for common watermark words
watermark_keywords = {"confidential", "draft", "copy", "sample", "watermark", "void"}
if text.strip().lower() in watermark_keywords:
return "watermark"
return "text"

View File

@@ -0,0 +1,292 @@
from __future__ import annotations
import uuid
from pathlib import Path
import cv2
import fitz # PyMuPDF
import numpy as np
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logging_config import get_logger
from app.models.document import Document, DocumentImage, DocumentPage, DocumentTable, DocumentTextBlock
from app.repositories.document_repository import (
DocumentImageRepository,
DocumentPageRepository,
DocumentRepository,
DocumentTableRepository,
DocumentTextBlockRepository,
)
from app.storage.provider import get_storage_provider
logger = get_logger(__name__)
class NativePDFService:
"""Extract text, fonts, images, and layout from native (non-scanned) PDFs using PyMuPDF."""
def __init__(self, db: Session) -> None:
self.db = db
self.storage = get_storage_provider()
self.doc_repo = DocumentRepository(db)
self.page_repo = DocumentPageRepository(db)
self.text_block_repo = DocumentTextBlockRepository(db)
self.image_repo = DocumentImageRepository(db)
self.table_repo = DocumentTableRepository(db)
def process_pdf(self, document: Document) -> Document:
"""Process a native PDF document, extracting all content."""
file_path = self.storage.get_absolute_path(document.storage_path)
try:
pdf_doc = fitz.open(file_path)
except Exception as e:
logger.error("pdf_open_failed", document_id=str(document.id), error=str(e))
raise
document.page_count = len(pdf_doc)
document.is_scanned = self._is_scanned_pdf(pdf_doc)
for page_num in range(len(pdf_doc)):
page = pdf_doc[page_num]
self._process_page(document, page, page_num + 1)
pdf_doc.close()
return document
def _is_scanned_pdf(self, pdf_doc: fitz.Document) -> bool:
"""Determine if a PDF is scanned (image-based) or native."""
total_text_chars = 0
total_images = 0
for page_num in range(min(len(pdf_doc), 3)):
page = pdf_doc[page_num]
text = page.get_text("text")
total_text_chars += len(text.strip())
total_images += len(page.get_images(full=True))
# If very little text but has images, likely scanned
if total_text_chars < 50 and total_images > 0:
return True
return False
def _process_page(self, document: Document, page: fitz.Page, page_number: int) -> DocumentPage:
"""Process a single PDF page."""
rect = page.rect
width = rect.width
height = rect.height
# Save page as image for reference
pix = page.get_pixmap(dpi=150)
image_filename = f"{document.id}_page_{page_number}.png"
image_data = pix.tobytes("png")
image_path = self.storage.save_file(image_data, "images", image_filename)
# Get full text content
text_content = page.get_text("text")
doc_page = self.page_repo.create_page(
document_id=document.id,
page_number=page_number,
width=width,
height=height,
image_path=image_path,
text_content=text_content,
)
# Extract text blocks with font information
self._extract_text_blocks(doc_page, page)
# Extract images
self._extract_images(doc_page, page, document)
# Detect headers and footers
self._detect_headers_footers(doc_page, page)
return doc_page
def _extract_text_blocks(self, doc_page: DocumentPage, page: fitz.Page) -> None:
"""Extract text blocks with positioning and font information."""
blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
sequence = 0
for block in blocks:
if block["type"] != 0: # Skip non-text blocks
continue
block_text_parts = []
font_info = {"family": None, "size": None, "color": None, "style": None}
for line in block.get("lines", []):
for span in line.get("spans", []):
text = span.get("text", "").strip()
if text:
block_text_parts.append(text)
# Capture font info from the first non-empty span
if font_info["family"] is None:
font_info["family"] = span.get("font", None)
font_info["size"] = span.get("size", None)
color_int = span.get("color", 0)
font_info["color"] = f"#{color_int:06x}" if isinstance(color_int, int) else None
flags = span.get("flags", 0)
styles = []
if flags & 1:
styles.append("superscript")
if flags & 2:
styles.append("italic")
if flags & 4:
styles.append("serif")
if flags & 8:
styles.append("monospace")
if flags & 16:
styles.append("bold")
font_info["style"] = ",".join(styles) if styles else "regular"
full_text = " ".join(block_text_parts)
if not full_text.strip():
continue
bbox = block["bbox"]
self.text_block_repo.create_text_block(
page_id=doc_page.id,
text=full_text,
x=bbox[0],
y=bbox[1],
width=bbox[2] - bbox[0],
height=bbox[3] - bbox[1],
font_family=font_info["family"],
font_size=font_info["size"],
font_color=font_info["color"],
font_style=font_info["style"],
block_type="text",
sequence=sequence,
)
sequence += 1
def _extract_images(self, doc_page: DocumentPage, page: fitz.Page, document: Document) -> None:
"""Extract embedded images from a PDF page."""
image_list = page.get_images(full=True)
for img_index, img_info in enumerate(image_list):
xref = img_info[0]
try:
base_image = page.parent.extract_image(xref)
if not base_image:
continue
image_bytes = base_image["image"]
ext = base_image.get("ext", "png")
img_filename = f"{document.id}_page_{doc_page.page_number}_img_{img_index}.{ext}"
img_storage_path = self.storage.save_file(image_bytes, "images", img_filename)
# Try to get image position on page
img_rects = page.get_image_rects(xref)
if img_rects:
rect = img_rects[0]
x, y, x1, y1 = rect.x0, rect.y0, rect.x1, rect.y1
else:
x, y, x1, y1 = 0, 0, base_image.get("width", 100), base_image.get("height", 100)
# Determine image type based on position
page_height = doc_page.height
page_width = doc_page.width
image_type = self._classify_image_type(x, y, x1, y1, page_width, page_height)
self.image_repo.create_image(
page_id=doc_page.id,
x=x,
y=y,
width=x1 - x,
height=y1 - y,
image_path=img_storage_path,
image_type=image_type,
)
except Exception as e:
logger.warning(
"image_extraction_failed",
page_id=str(doc_page.id),
img_index=img_index,
error=str(e),
)
def _classify_image_type(
self,
x: float,
y: float,
x1: float,
y1: float,
page_width: float,
page_height: float,
) -> str:
"""Classify an image as logo, figure, background, or stamp based on position and size."""
width = x1 - x
height = y1 - y
area_ratio = (width * height) / (page_width * page_height) if page_width > 0 and page_height > 0 else 0
# Background: covers most of the page
if area_ratio > 0.8:
return "background"
# Logo: small image in top portion
if y < page_height * 0.15 and area_ratio < 0.05:
return "logo"
# Stamp: small image in bottom-right
if x > page_width * 0.6 and y > page_height * 0.7 and area_ratio < 0.05:
return "stamp"
return "figure"
def _detect_headers_footers(self, doc_page: DocumentPage, page: fitz.Page) -> None:
"""Detect header and footer regions based on vertical position."""
page_height = page.rect.height
header_threshold = page_height * 0.1
footer_threshold = page_height * 0.9
blocks = page.get_text("dict")["blocks"]
header_seq = 0
footer_seq = 0
for block in blocks:
if block["type"] != 0:
continue
bbox = block["bbox"]
block_y = bbox[1]
text_parts = []
for line in block.get("lines", []):
for span in line.get("spans", []):
t = span.get("text", "").strip()
if t:
text_parts.append(t)
full_text = " ".join(text_parts)
if not full_text.strip():
continue
if block_y < header_threshold:
self.text_block_repo.create_text_block(
page_id=doc_page.id,
text=full_text,
x=bbox[0],
y=bbox[1],
width=bbox[2] - bbox[0],
height=bbox[3] - bbox[1],
block_type="header",
sequence=header_seq,
)
header_seq += 1
elif block_y > footer_threshold:
self.text_block_repo.create_text_block(
page_id=doc_page.id,
text=full_text,
x=bbox[0],
y=bbox[1],
width=bbox[2] - bbox[0],
height=bbox[3] - bbox[1],
block_type="footer",
sequence=footer_seq,
)
footer_seq += 1

View File

@@ -0,0 +1,455 @@
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()

View File

@@ -0,0 +1,401 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging_config import get_logger
from app.models.document import Document
from app.models.template import (
DocumentCell,
DocumentFormat,
DocumentRegion,
ImageRegion,
TableColumn,
TableFormat,
TableRow,
Watermark,
)
from app.repositories.document_repository import DocumentRepository
from app.repositories.template_repository import (
DocumentCellRepository,
DocumentRegionRepository,
ImageRegionRepository,
TableColumnRepository,
TableFormatRepository,
TableRowRepository,
TemplateRepository,
WatermarkRepository,
)
from app.services.fingerprint_service import FingerprintService
logger = get_logger(__name__)
class TemplateService:
"""Generate reusable document templates from processed documents."""
def __init__(self, db: Session) -> None:
self.db = db
self.doc_repo = DocumentRepository(db)
self.template_repo = TemplateRepository(db)
self.cell_repo = DocumentCellRepository(db)
self.region_repo = DocumentRegionRepository(db)
self.table_format_repo = TableFormatRepository(db)
self.table_column_repo = TableColumnRepository(db)
self.table_row_repo = TableRowRepository(db)
self.watermark_repo = WatermarkRepository(db)
self.image_region_repo = ImageRegionRepository(db)
self.fingerprint_service = FingerprintService(db)
def generate_template(
self,
document: Document,
user_id: uuid.UUID | None = None,
) -> DocumentFormat:
"""Generate a reusable template from a processed document."""
if not document.pages:
raise ValueError(f"Document '{document.id}' has no processed pages")
# Check if template already exists for this document
existing = self.template_repo.get_by_source_document(document.id)
if existing:
logger.info(
"template_already_exists",
document_id=str(document.id),
template_id=str(existing.id),
)
return existing
first_page = document.pages[0]
template_name = f"Template_{document.original_filename}_{uuid.uuid4().hex[:8]}"
# Create template
template = self.template_repo.create_template(
name=template_name,
page_width=first_page.width,
page_height=first_page.height,
page_count=document.page_count or len(document.pages),
description=f"Auto-generated template from {document.original_filename}",
source_document_id=document.id,
created_by=user_id,
)
# Process each page
for page in document.pages:
self._process_page_for_template(template, page)
# Generate fingerprint
self.fingerprint_service.generate_fingerprint(template)
logger.info(
"template_generated",
template_id=str(template.id),
document_id=str(document.id),
cells=len(template.cells),
regions=len(template.regions),
)
return template
def _process_page_for_template(
self,
template: DocumentFormat,
page: Any,
) -> None:
"""Process a document page and create template components."""
page_number = page.page_number
# Create cells from text blocks
self._create_cells_from_text_blocks(template, page, page_number)
# Create regions from headers, footers
self._create_regions(template, page, page_number)
# Create table formats
self._create_table_formats(template, page, page_number)
# Create image regions
self._create_image_regions(template, page, page_number)
# Detect watermarks from text blocks
self._create_watermarks(template, page, page_number)
def _create_cells_from_text_blocks(
self,
template: DocumentFormat,
page: Any,
page_number: int,
) -> None:
"""Create template cells from extracted text blocks."""
for seq, block in enumerate(page.text_blocks):
if block.block_type in ("header", "footer", "watermark"):
continue
# Determine if this is a dynamic field
is_dynamic = self._is_dynamic_field(block.text)
field_name = self._generate_field_name(block.text, seq) if is_dynamic else None
self.cell_repo.create_cell(
format_id=template.id,
page_number=page_number,
x=block.x,
y=block.y,
width=block.width,
height=block.height,
data_type=self._infer_data_type(block.text),
font_family=block.font_family,
font_size=block.font_size,
font_style=block.font_style,
font_color=block.font_color,
alignment=self._infer_alignment(block.x, template.page_width),
static_text=block.text if not is_dynamic else None,
field_name=field_name,
sequence=seq,
is_dynamic=is_dynamic,
)
def _create_regions(
self,
template: DocumentFormat,
page: Any,
page_number: int,
) -> None:
"""Create template regions from headers and footers."""
header_blocks = [b for b in page.text_blocks if b.block_type == "header"]
footer_blocks = [b for b in page.text_blocks if b.block_type == "footer"]
if header_blocks:
# Compute bounding box for all header blocks
min_x = min(b.x for b in header_blocks)
min_y = min(b.y for b in header_blocks)
max_x = max(b.x + b.width for b in header_blocks)
max_y = max(b.y + b.height for b in header_blocks)
content = {
"blocks": [
{
"text": b.text,
"x": b.x,
"y": b.y,
"width": b.width,
"height": b.height,
"font_family": b.font_family,
"font_size": b.font_size,
}
for b in header_blocks
]
}
self.region_repo.create_region(
format_id=template.id,
page_number=page_number,
region_type="header",
x=min_x,
y=min_y,
width=max_x - min_x,
height=max_y - min_y,
content=content,
sequence=0,
)
if footer_blocks:
min_x = min(b.x for b in footer_blocks)
min_y = min(b.y for b in footer_blocks)
max_x = max(b.x + b.width for b in footer_blocks)
max_y = max(b.y + b.height for b in footer_blocks)
content = {
"blocks": [
{
"text": b.text,
"x": b.x,
"y": b.y,
"width": b.width,
"height": b.height,
"font_family": b.font_family,
"font_size": b.font_size,
}
for b in footer_blocks
]
}
self.region_repo.create_region(
format_id=template.id,
page_number=page_number,
region_type="footer",
x=min_x,
y=min_y,
width=max_x - min_x,
height=max_y - min_y,
content=content,
sequence=1,
)
def _create_table_formats(
self,
template: DocumentFormat,
page: Any,
page_number: int,
) -> None:
"""Create table format definitions from detected tables."""
for table in page.tables:
table_format = self.table_format_repo.create_table_format(
format_id=template.id,
page_number=page_number,
x=table.x,
y=table.y,
width=table.width,
height=table.height,
rows=table.rows,
columns=table.columns,
)
# Create columns
col_width = table.width / max(table.columns, 1)
for col_idx in range(table.columns):
self.table_column_repo.create_column(
table_format_id=table_format.id,
column_index=col_idx,
width=col_width,
data_type="text",
alignment="left",
)
# Create rows
row_height = table.height / max(table.rows, 1)
for row_idx in range(table.rows):
self.table_row_repo.create_row(
table_format_id=table_format.id,
row_index=row_idx,
height=row_height,
is_header=(row_idx == 0),
)
def _create_image_regions(
self,
template: DocumentFormat,
page: Any,
page_number: int,
) -> None:
"""Create image region definitions from detected images."""
for img in page.images:
self.image_region_repo.create_image_region(
format_id=template.id,
page_number=page_number,
x=img.x,
y=img.y,
width=img.width,
height=img.height,
image_path=img.image_path,
image_type=img.image_type,
is_static=True,
)
def _create_watermarks(
self,
template: DocumentFormat,
page: Any,
page_number: int,
) -> None:
"""Create watermark definitions from detected watermark text blocks."""
watermark_blocks = [b for b in page.text_blocks if b.block_type == "watermark"]
for block in watermark_blocks:
self.watermark_repo.create_watermark(
format_id=template.id,
page_number=page_number,
text=block.text,
x=block.x,
y=block.y,
width=block.width,
height=block.height,
opacity=0.3,
rotation=0.0,
font_family=block.font_family,
font_size=block.font_size,
font_color=block.font_color or "#CCCCCC",
)
def _is_dynamic_field(self, text: str) -> bool:
"""Determine if a text block represents a dynamic (variable) field."""
if not text:
return False
# Common patterns indicating dynamic content
dynamic_patterns = [
"{{", "}}", "${", "##",
"__________", "___", "...........",
]
for pattern in dynamic_patterns:
if pattern in text:
return True
# Short single-word values that might be labels are static
# Longer values with numbers/dates tend to be dynamic
import re
# Date patterns
if re.search(r"\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}", text):
return True
# Currency patterns
if re.search(r"[$€£¥]\s*[\d,]+\.?\d*", text):
return True
# Phone patterns
if re.search(r"\+?\d[\d\s\-()]{7,}", text):
return True
return False
def _generate_field_name(self, text: str, sequence: int) -> str:
"""Generate a field name from text content."""
import re
# Clean text
clean = re.sub(r"[^a-zA-Z0-9\s]", "", text)
clean = clean.strip().lower()
words = clean.split()[:3]
if words:
return "_".join(words)
return f"field_{sequence}"
def _infer_data_type(self, text: str) -> str:
"""Infer the data type from text content."""
import re
if not text:
return "text"
stripped = text.strip()
# Number
if re.match(r"^-?[\d,]+\.?\d*$", stripped.replace(",", "")):
return "number"
# Date
if re.search(r"\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}", stripped):
return "date"
# Currency
if re.search(r"^[$€£¥]\s*[\d,]+\.?\d*$", stripped):
return "currency"
# Email
if re.search(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", stripped):
return "email"
return "text"
def _infer_alignment(self, x: float, page_width: float) -> str:
"""Infer text alignment based on horizontal position."""
if page_width <= 0:
return "left"
relative_x = x / page_width
if relative_x < 0.15:
return "left"
elif relative_x > 0.6:
return "right"
elif 0.35 < relative_x < 0.65:
return "center"
return "left"