""" PDF Exporter Handles PDF report generation and export functionality using ReportLab. """ import os import time import tempfile from datetime import datetime from typing import Dict, List, Tuple, Optional from PyQt5.QtWidgets import QMessageBox, QFileDialog from PyQt5.QtGui import QPixmap try: from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak from reportlab.lib.enums import TA_CENTER, TA_LEFT HAS_REPORTLAB = True except ImportError: HAS_REPORTLAB = False class PDFExporter: """ Handles PDF report generation and export. Attributes: has_reportlab: Boolean indicating if ReportLab is available """ def __init__(self): """Initialize the PDF exporter.""" self.has_reportlab = HAS_REPORTLAB def export_report( self, parent_widget, report_id: str, report_content: Dict, visualizations: List[Tuple[str, QPixmap]], include_visualizations: bool = True ) -> bool: """ Export report as PDF. Args: parent_widget: Parent widget for dialogs report_id: Report ID for filename report_content: Dictionary with report text content visualizations: List of (title, QPixmap) tuples include_visualizations: Whether to include visualizations Returns: bool: True if export succeeded, False otherwise """ if not self.has_reportlab: QMessageBox.critical( parent_widget, "Library Not Available", "ReportLab is not installed. Please install it to use PDF export:\n\n" "pip install reportlab" ) return False # Get file path from user file_path, _ = QFileDialog.getSaveFileName( parent_widget, "Save PDF Report", f"Report_{report_id or datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", "PDF Files (*.pdf);;All Files (*.*)", options=QFileDialog.DontUseNativeDialog ) if not file_path: return False # User cancelled try: # Create PDF document doc = SimpleDocTemplate(file_path, pagesize=A4) story = [] # Add title styles = getSampleStyleSheet() title_style = ParagraphStyle( 'CustomTitle', parent=styles['Heading1'], fontSize=18, textColor=colors.HexColor('#2c3e50'), spaceAfter=20, alignment=TA_CENTER ) story.append(Paragraph("Durian Analysis Report", title_style)) story.append(Spacer(1, 0.2*inch)) # Report Information heading_style = ParagraphStyle( 'CustomHeading', parent=styles['Heading2'], fontSize=12, textColor=colors.HexColor('#34495e'), spaceAfter=10 ) story.append(Paragraph("Report Information", heading_style)) info_data = [ ['Report ID:', report_content['report_id']], ['Generated:', report_content['generated']] ] info_table = Table(info_data, colWidths=[2*inch, 3.5*inch]) info_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#ecf0f1')), ('TEXTCOLOR', (0, 0), (-1, -1), colors.black), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 1, colors.grey) ])) story.append(info_table) story.append(Spacer(1, 0.3*inch)) # Analysis Results story.append(Paragraph("Analysis Results", heading_style)) results = report_content['results'] results_data = [['Metric', 'Value']] if results.get('locule_count') is not None: results_data.append(['Locule Count', f"{results['locule_count']} locules"]) if results.get('defect_status'): results_data.append(['Defect Status', f"{results['defect_status']} ({results.get('total_detections', 0)} detections)"]) if results.get('shape_class'): results_data.append(['Shape', f"{results['shape_class']} ({results.get('shape_confidence', 0)*100:.1f}%)"]) if results.get('maturity_class'): results_data.append(['Maturity', f"{results['maturity_class']} ({results.get('maturity_confidence', 0)*100:.1f}%)"]) if results.get('ripeness_class'): results_data.append(['Ripeness', f"{results['ripeness_class']} ({results.get('ripeness_confidence', 0)*100:.1f}%)"]) results_table = Table(results_data, colWidths=[2*inch, 3.5*inch]) results_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 1, colors.grey), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ecf0f1')]) ])) story.append(results_table) story.append(Spacer(1, 0.3*inch)) # Overall Grade grade_color = {'A': '#27ae60', 'B': '#f39c12', 'C': '#e74c3c'}[report_content['grade']] grade_data = [ [f"Overall Grade: Class {report_content['grade']}"], [report_content['grade_description']] ] grade_table = Table(grade_data, colWidths=[5.5*inch]) grade_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor(grade_color)), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 14), ('FONTSIZE', (0, 1), (-1, 1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 10), ('TOPPADDING', (0, 0), (-1, -1), 10), ('GRID', (0, 0), (-1, -1), 2, colors.HexColor(grade_color)) ])) story.append(grade_table) story.append(Spacer(1, 0.3*inch)) # Add visualizations if available if include_visualizations and visualizations: story.append(PageBreak()) story.append(Paragraph("Visualizations", heading_style)) story.append(Spacer(1, 0.2*inch)) for viz_idx, (viz_title, viz_pixmap) in enumerate(visualizations): # Scale pixmap to PDF size max_width_points = 4.5*inch max_height_points = 4*inch pixmap = viz_pixmap # Scale if necessary, maintaining aspect ratio if pixmap.width() > max_width_points or pixmap.height() > max_height_points: aspect_ratio = pixmap.width() / pixmap.height() # Scale to fit within bounds new_height = int(max_height_points) new_width = int(new_height * aspect_ratio) if new_width > max_width_points: new_width = int(max_width_points) new_height = int(new_width / aspect_ratio) pixmap = pixmap.scaled(new_width, new_height, 1, 1) # Qt.KeepAspectRatio, Qt.SmoothTransformation # Save pixmap to temporary image file temp_img_path = os.path.join(tempfile.gettempdir(), f"report_viz_{int(time.time()*1000000)}_{viz_idx}.png") pixmap.save(temp_img_path, "PNG") try: # Add to PDF with calculated dimensions to fit properly story.append(Paragraph(viz_title, heading_style)) # Use width with height=None to let ReportLab calculate proportional height img = RLImage(temp_img_path, width=4*inch, height=None) story.append(img) story.append(Spacer(1, 0.2*inch)) except Exception as e: print(f"Error adding image to PDF: {e}") # Build PDF doc.build(story) # Auto-open the PDF file try: if os.name == 'nt': # Windows os.startfile(file_path) elif os.name == 'posix': # macOS/Linux import subprocess subprocess.Popen(['open', file_path]) except Exception: pass QMessageBox.information( parent_widget, "PDF Exported Successfully", f"Report exported to:\n{file_path}" ) return True except Exception as e: QMessageBox.critical( parent_widget, "PDF Export Error", f"Error creating PDF:\n{str(e)}" ) return False