Spaces:
Sleeping
Sleeping
Download pdf_service.py from cooldan/SPaCial_server_api: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/cooldan/SPaCial_server_api/resolve/main/pdf_service.py
- Command line
-
hf download hf://spaces/cooldan/SPaCial_server_api/pdf_service.py
-
curl -L -o pdf_service.py https://huggingface.co/spaces/cooldan/SPaCial_server_api/resolve/main/pdf_service.py
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() | |