SPaCial_server_api / pdf_service.py
cooldan's picture
Upload 2 files
2b96133 verified
Raw History Blame Contribute Delete
21.3 kB
"""
PDF Export Service for OCR Canvas App
Generates PDF reports with image, bubbles, and tolerance grid
"""
import io
import base64
from typing import List, Dict, Any, Optional
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.units import inch, cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
from reportlab.platypus.flowables import HRFlowable
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from PIL import Image as PILImage
import logging
logger = logging.getLogger(__name__)
class PDFExportService:
"""Service for generating PDF reports with OCR data and tolerance information"""
def __init__(self):
self.page_width, self.page_height = A4
self.margin = 1 * inch
self.content_width = self.page_width - (2 * self.margin)
def generate_pdf_report(
self,
image_data: bytes,
zones: List[Dict[str, Any]],
title: str = "OCR Measurement Report",
part_number: str = "Part 1"
) -> bytes:
"""
Generate a comprehensive PDF report with image, bubbles, and tolerance grid
Args:
image_data: Image bytes
zones: List of zone data with OCR results and tolerance info
title: Report title
part_number: Part identifier (Part 1, Part 2, etc.)
Returns:
PDF bytes
"""
try:
# Validate inputs
if not image_data:
raise ValueError("No image data provided")
if not zones:
zones = [] # Allow empty zones list
if not isinstance(zones, list):
raise ValueError("Zones must be a list")
# Create PDF buffer
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=self.margin,
leftMargin=self.margin,
topMargin=self.margin,
bottomMargin=self.margin
)
# Build PDF content
story = []
# Add header
story.extend(self._create_header(title, part_number))
story.append(Spacer(1, 0.2 * inch))
# Add image section
story.extend(self._create_image_section(image_data, zones))
story.append(Spacer(1, 0.3 * inch))
# Add tolerance grid
story.extend(self._create_tolerance_grid(zones))
# Build PDF
doc.build(story)
# Get PDF bytes
pdf_bytes = buffer.getvalue()
buffer.close()
logger.info(f"Generated PDF report with {len(zones)} zones")
return pdf_bytes
except Exception as e:
logger.error(f"Error generating PDF: {e}")
raise
def _create_header(self, title: str, part_number: str) -> List:
"""Create professional PDF header section"""
styles = getSampleStyleSheet()
story = []
# Company/Report title with enhanced styling
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
spaceAfter=8,
alignment=TA_CENTER,
textColor=colors.HexColor('#1565c0'),
fontName='Helvetica-Bold',
borderWidth=2,
borderColor=colors.HexColor('#1565c0'),
borderPadding=10,
backColor=colors.HexColor('#f3f4f6')
)
subtitle_style = ParagraphStyle(
'CustomSubtitle',
parent=styles['Heading2'],
fontSize=16,
spaceAfter=8,
alignment=TA_CENTER,
textColor=colors.HexColor('#374151'),
fontName='Helvetica-Bold'
)
# Add metadata section
metadata_style = ParagraphStyle(
'Metadata',
parent=styles['Normal'],
fontSize=10,
spaceAfter=4,
alignment=TA_LEFT,
textColor=colors.HexColor('#6b7280')
)
from datetime import datetime
current_date = datetime.now().strftime("%B %d, %Y")
current_time = datetime.now().strftime("%H:%M:%S")
story = [
Paragraph(title, title_style),
Paragraph(f"{part_number} - Dimensional Analysis Report", subtitle_style),
Spacer(1, 0.1 * inch),
# Metadata table
Table([
['Report Date:', current_date, 'Generated:', current_time],
['Part Number:', part_number, 'Pages:', '1']
], colWidths=[1.5*inch, 2*inch, 1.5*inch, 2*inch], style=TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
])),
HRFlowable(width="100%", thickness=2, lineCap='round', color=colors.HexColor('#1565c0')),
Spacer(1, 0.15 * inch)
]
return story
def _create_image_section(self, image_data: bytes, zones: List[Dict[str, Any]]) -> List:
"""Create image section with bubbles"""
try:
# Process image
pil_image = PILImage.open(io.BytesIO(image_data))
# Resize image to fit page (max width: 6 inches)
max_width = 6 * inch
max_height = 4 * inch
# Calculate scaling
img_width, img_height = pil_image.size
scale_x = max_width / img_width
scale_y = max_height / img_height
scale = min(scale_x, scale_y, 1.0) # Don't scale up
new_width = img_width * scale
new_height = img_height * scale
# Resize image
pil_image = pil_image.resize((int(new_width), int(new_height)), PILImage.Resampling.LANCZOS)
# Create image with bubbles
image_with_bubbles = self._add_bubbles_to_image(pil_image, zones, scale)
# Convert to bytes for ReportLab
img_buffer = io.BytesIO()
image_with_bubbles.save(img_buffer, format='PNG')
img_buffer.seek(0)
# Add section title
styles = getSampleStyleSheet()
section_style = ParagraphStyle(
'SectionTitle',
parent=styles['Heading3'],
fontSize=14,
spaceAfter=8,
spaceBefore=8,
alignment=TA_CENTER,
textColor=colors.HexColor('#1565c0'),
fontName='Helvetica-Bold'
)
# Add image to PDF with professional styling
img = Image(img_buffer, width=new_width, height=new_height)
img.hAlign = 'CENTER'
# Create a table to add border around the image
image_table = Table([[img]], colWidths=[new_width + 20], rowHeights=[new_height + 20])
image_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOX', (0, 0), (-1, -1), 2, colors.HexColor('#1565c0')),
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8fafc')),
('PADDING', (0, 0), (-1, -1), 10),
]))
return [
Paragraph("Technical Drawing with Dimension Markers", section_style),
image_table,
Spacer(1, 0.1 * inch)
]
except Exception as e:
logger.error(f"Error creating image section: {e}")
return [Paragraph("Error loading image", getSampleStyleSheet()['Normal'])]
def _add_bubbles_to_image(self, pil_image: PILImage.Image, zones: List[Dict[str, Any]], scale: float) -> PILImage.Image:
"""Add numbered bubbles to the image"""
try:
from PIL import ImageDraw, ImageFont
# Create a copy to draw on
img_with_bubbles = pil_image.copy()
draw = ImageDraw.Draw(img_with_bubbles)
# Try to use a font, fallback to default if not available
# Use same font size as canvas: bold 13px Arial
font_size = 13 # Same as canvas
try:
font = ImageFont.truetype("arial.ttf", font_size)
except:
font = ImageFont.load_default()
# Draw bubbles for each zone
for i, zone in enumerate(zones, 1):
try:
if not zone or not isinstance(zone, dict):
logger.warning(f"PDF Service: Skipping invalid zone for bubble {i}: {type(zone)}")
continue
bbox = zone.get('bbox', {})
if not bbox or not isinstance(bbox, dict):
logger.warning(f"PDF Service: Skipping zone {i} with invalid bbox: {type(bbox)}")
continue
# Get bubble position exactly like canvas
bubbleRadius = 15 # Same as canvas
defaultOffsetX = -bubbleRadius + 5 # Same as canvas: -bubbleRadius + 5
defaultOffsetY = -bubbleRadius + 5 # Same as canvas: -bubbleRadius + 5
# Use stored bubble position or smart default positioning (like canvas)
bubbleOffset = zone.get('bubbleOffset', {})
if bubbleOffset:
offsetX = bubbleOffset.get('x', defaultOffsetX)
offsetY = bubbleOffset.get('y', defaultOffsetY)
else:
offsetX = defaultOffsetX
offsetY = defaultOffsetY
# Calculate bubble position with same logic as canvas
bubble_x = int((bbox.get('x1', 0) + offsetX) * scale)
bubble_y = int((bbox.get('y1', 0) + offsetY) * scale)
# Use same radius as canvas
radius = bubbleRadius
left = bubble_x - radius
top = bubble_y - radius
right = bubble_x + radius
bottom = bubble_y + radius
# Draw bubble exactly like canvas - translucent blue background with white border
# Create a semi-transparent blue color
blue_color = (0, 136, 255) # RGB values for the blue
draw.ellipse([left, top, right, bottom], fill=blue_color, outline='white', width=2)
# Draw number with dark text (like canvas)
text = str(i)
bbox_text = draw.textbbox((0, 0), text, font=font)
text_width = bbox_text[2] - bbox_text[0]
text_height = bbox_text[3] - bbox_text[1]
text_x = bubble_x - (text_width // 2)
text_y = bubble_y - (text_height // 2)
# Draw number with dark gray text (like canvas)
draw.text((text_x, text_y), text, fill='#1a1a1a', font=font)
except Exception as e:
logger.warning(f"Error drawing bubble for zone {i}: {e}")
continue
return img_with_bubbles
except Exception as e:
logger.error(f"Error adding bubbles to image: {e}")
return pil_image
def _create_tolerance_grid(self, zones: List[Dict[str, Any]]) -> List:
"""Create tolerance information grid table for multiple parts"""
styles = getSampleStyleSheet()
# Table header
header_style = ParagraphStyle(
'TableHeader',
parent=styles['Heading3'],
fontSize=14,
spaceAfter=12,
alignment=TA_CENTER,
textColor=colors.darkblue
)
story = [
Paragraph("Measurement Data Grid", header_style),
Spacer(1, 0.1 * inch)
]
# Create table data - compact format for multiple parts
# Header row with multi-line headers
header_row = [
'OCR\nValue',
'Min\nTol',
'Max\nTol',
'Mid\nValue'
]
# Add 10 columns for different parts
for i in range(1, 11):
header_row.append(f'Part {i}\nActual')
table_data = [header_row]
for i, zone in enumerate(zones):
try:
# Extract data with better error handling
if not zone or not isinstance(zone, dict):
logger.warning(f"PDF Service: Skipping invalid zone at index {i}: {type(zone)}")
continue
text = zone.get('text', 'N/A')
tolerance_info = zone.get('tolerance_info', {})
# Ensure tolerance_info is a dictionary
if tolerance_info is None:
tolerance_info = {}
min_tol = tolerance_info.get('min_tolerance', '') if tolerance_info else ''
max_tol = tolerance_info.get('max_tolerance', '') if tolerance_info else ''
mid_val = tolerance_info.get('middle_value', '') if tolerance_info else ''
except Exception as e:
logger.error(f"PDF Service: Error processing zone at index {i}: {e}")
continue
# Add row to table - no reference number, just data
row = [
text, # OCR Value
min_tol, # Min Tolerance
max_tol, # Max Tolerance
mid_val, # Mid Value
]
# Add 10 empty columns for actual measurements
for i in range(10):
row.append('') # Empty for user to fill
table_data.append(row)
# Create table with compact column widths
col_widths = [
0.7 * inch, # OCR Value
0.5 * inch, # Min Tol
0.5 * inch, # Max Tol
0.5 * inch, # Mid Value
]
# Add 10 columns for parts (0.6 inch each)
for i in range(10):
col_widths.append(0.6 * inch)
table = Table(table_data, colWidths=col_widths)
# Style the table with professional colors
table_style = TableStyle([
# Header row - professional blue gradient
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1565c0')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
('TOPPADDING', (0, 0), (-1, 0), 10),
('LEFTPADDING', (0, 0), (-1, 0), 4),
('RIGHTPADDING', (0, 0), (-1, 0), 4),
# Data rows
('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 1), (-1, -1), 8),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 1), (-1, -1), 4),
('RIGHTPADDING', (0, 1), (-1, -1), 4),
('TOPPADDING', (0, 1), (-1, -1), 6),
('BOTTOMPADDING', (0, 1), (-1, -1), 6),
# Alternating row colors - subtle professional colors
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.HexColor('#f8fafc'), colors.HexColor('#f1f5f9')]),
# OCR Value column styling
('BACKGROUND', (0, 1), (0, -1), colors.HexColor('#dbeafe')),
('FONTNAME', (0, 1), (0, -1), 'Helvetica-Bold'),
# Tolerance columns styling
('BACKGROUND', (1, 1), (3, -1), colors.HexColor('#f0f9ff')),
('FONTNAME', (1, 1), (3, -1), 'Helvetica'),
# Measurement cells styling - highlight empty cells for user input
('BACKGROUND', (4, 1), (-1, -1), colors.white),
('BOX', (4, 1), (-1, -1), 1, colors.HexColor('#3b82f6')),
('LINEBELOW', (4, 1), (-1, -1), 1, colors.HexColor('#3b82f6')),
('LINEABOVE', (4, 1), (-1, -1), 1, colors.HexColor('#3b82f6')),
('FONTNAME', (4, 1), (-1, -1), 'Helvetica'),
('FONTSIZE', (4, 1), (-1, -1), 8),
])
table.setStyle(table_style)
story.append(table)
# Add professional footer section
footer_style = ParagraphStyle(
'FooterNote',
parent=styles['Normal'],
fontSize=9,
spaceBefore=8,
spaceAfter=4,
alignment=TA_LEFT,
textColor=colors.HexColor('#374151'),
fontName='Helvetica'
)
footer_title_style = ParagraphStyle(
'FooterTitle',
parent=styles['Heading4'],
fontSize=10,
spaceBefore=16,
spaceAfter=8,
alignment=TA_LEFT,
textColor=colors.HexColor('#1565c0'),
fontName='Helvetica-Bold'
)
# Create footer table with instructions
footer_data = [
['Instructions:', 'Fill in the "Part X Actual" columns with measured values during inspection'],
['Legend:', 'OCR Value: Detected text from image analysis'],
['', 'Min/Max Tol: Tolerance range for dimensional control'],
['', 'Mid Value: Calculated middle value for reference']
]
footer_table = Table(footer_data, colWidths=[1.2*inch, 5.8*inch])
footer_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('ALIGN', (0, 0), (0, -1), 'LEFT'),
('ALIGN', (1, 0), (1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#1565c0')),
]))
story.extend([
Spacer(1, 0.25 * inch),
Paragraph("Document Information", footer_title_style),
footer_table,
Spacer(1, 0.15 * inch),
Table([
['Inspector Signature:', '_' * 30, 'Date:', '_' * 15],
['Quality Manager:', '_' * 30, 'Approval:', '_' * 15]
], colWidths=[1.5*inch, 3*inch, 0.8*inch, 1.7*inch], style=TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'BOTTOM'),
('LINEBELOW', (1, 0), (1, 0), 1, colors.black),
('LINEBELOW', (3, 0), (3, 0), 1, colors.black),
('LINEBELOW', (1, 1), (1, 1), 1, colors.black),
('LINEBELOW', (3, 1), (3, 1), 1, colors.black),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
])
return story
# Global instance
pdf_service = PDFExportService()