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