pdf_exporter.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. """
  2. PDF Exporter
  3. Handles PDF report generation and export functionality using ReportLab.
  4. """
  5. import os
  6. import time
  7. import tempfile
  8. from datetime import datetime
  9. from typing import Dict, List, Tuple, Optional
  10. from PyQt5.QtWidgets import QMessageBox, QFileDialog
  11. from PyQt5.QtGui import QPixmap
  12. try:
  13. from reportlab.lib import colors
  14. from reportlab.lib.pagesizes import A4
  15. from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
  16. from reportlab.lib.units import inch
  17. from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak
  18. from reportlab.lib.enums import TA_CENTER, TA_LEFT
  19. HAS_REPORTLAB = True
  20. except ImportError:
  21. HAS_REPORTLAB = False
  22. class PDFExporter:
  23. """
  24. Handles PDF report generation and export.
  25. Attributes:
  26. has_reportlab: Boolean indicating if ReportLab is available
  27. """
  28. def __init__(self):
  29. """Initialize the PDF exporter."""
  30. self.has_reportlab = HAS_REPORTLAB
  31. def export_report(
  32. self,
  33. parent_widget,
  34. report_id: str,
  35. report_content: Dict,
  36. visualizations: List[Tuple[str, QPixmap]],
  37. include_visualizations: bool = True
  38. ) -> bool:
  39. """
  40. Export report as PDF.
  41. Args:
  42. parent_widget: Parent widget for dialogs
  43. report_id: Report ID for filename
  44. report_content: Dictionary with report text content
  45. visualizations: List of (title, QPixmap) tuples
  46. include_visualizations: Whether to include visualizations
  47. Returns:
  48. bool: True if export succeeded, False otherwise
  49. """
  50. if not self.has_reportlab:
  51. QMessageBox.critical(
  52. parent_widget,
  53. "Library Not Available",
  54. "ReportLab is not installed. Please install it to use PDF export:\n\n"
  55. "pip install reportlab"
  56. )
  57. return False
  58. # Get file path from user
  59. file_path, _ = QFileDialog.getSaveFileName(
  60. parent_widget,
  61. "Save PDF Report",
  62. f"Report_{report_id or datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf",
  63. "PDF Files (*.pdf);;All Files (*.*)",
  64. options=QFileDialog.DontUseNativeDialog
  65. )
  66. if not file_path:
  67. return False # User cancelled
  68. try:
  69. # Create PDF document
  70. doc = SimpleDocTemplate(file_path, pagesize=A4)
  71. story = []
  72. # Add title
  73. styles = getSampleStyleSheet()
  74. title_style = ParagraphStyle(
  75. 'CustomTitle',
  76. parent=styles['Heading1'],
  77. fontSize=18,
  78. textColor=colors.HexColor('#2c3e50'),
  79. spaceAfter=20,
  80. alignment=TA_CENTER
  81. )
  82. story.append(Paragraph("Durian Analysis Report", title_style))
  83. story.append(Spacer(1, 0.2*inch))
  84. # Report Information
  85. heading_style = ParagraphStyle(
  86. 'CustomHeading',
  87. parent=styles['Heading2'],
  88. fontSize=12,
  89. textColor=colors.HexColor('#34495e'),
  90. spaceAfter=10
  91. )
  92. story.append(Paragraph("Report Information", heading_style))
  93. info_data = [
  94. ['Report ID:', report_content['report_id']],
  95. ['Generated:', report_content['generated']]
  96. ]
  97. info_table = Table(info_data, colWidths=[2*inch, 3.5*inch])
  98. info_table.setStyle(TableStyle([
  99. ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#ecf0f1')),
  100. ('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
  101. ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
  102. ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
  103. ('FONTSIZE', (0, 0), (-1, -1), 10),
  104. ('BOTTOMPADDING', (0, 0), (-1, -1), 8),
  105. ('TOPPADDING', (0, 0), (-1, -1), 8),
  106. ('GRID', (0, 0), (-1, -1), 1, colors.grey)
  107. ]))
  108. story.append(info_table)
  109. story.append(Spacer(1, 0.3*inch))
  110. # Analysis Results
  111. story.append(Paragraph("Analysis Results", heading_style))
  112. results = report_content['results']
  113. results_data = [['Metric', 'Value']]
  114. if results.get('locule_count') is not None:
  115. results_data.append(['Locule Count', f"{results['locule_count']} locules"])
  116. if results.get('defect_status'):
  117. results_data.append(['Defect Status', f"{results['defect_status']} ({results.get('total_detections', 0)} detections)"])
  118. if results.get('shape_class'):
  119. results_data.append(['Shape', f"{results['shape_class']} ({results.get('shape_confidence', 0)*100:.1f}%)"])
  120. if results.get('maturity_class'):
  121. results_data.append(['Maturity', f"{results['maturity_class']} ({results.get('maturity_confidence', 0)*100:.1f}%)"])
  122. if results.get('ripeness_class'):
  123. results_data.append(['Ripeness', f"{results['ripeness_class']} ({results.get('ripeness_confidence', 0)*100:.1f}%)"])
  124. results_table = Table(results_data, colWidths=[2*inch, 3.5*inch])
  125. results_table.setStyle(TableStyle([
  126. ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')),
  127. ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
  128. ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
  129. ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
  130. ('FONTSIZE', (0, 0), (-1, -1), 10),
  131. ('BOTTOMPADDING', (0, 0), (-1, -1), 8),
  132. ('TOPPADDING', (0, 0), (-1, -1), 8),
  133. ('GRID', (0, 0), (-1, -1), 1, colors.grey),
  134. ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ecf0f1')])
  135. ]))
  136. story.append(results_table)
  137. story.append(Spacer(1, 0.3*inch))
  138. # Overall Grade
  139. grade_color = {'A': '#27ae60', 'B': '#f39c12', 'C': '#e74c3c'}[report_content['grade']]
  140. grade_data = [
  141. [f"Overall Grade: Class {report_content['grade']}"],
  142. [report_content['grade_description']]
  143. ]
  144. grade_table = Table(grade_data, colWidths=[5.5*inch])
  145. grade_table.setStyle(TableStyle([
  146. ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor(grade_color)),
  147. ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
  148. ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
  149. ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
  150. ('FONTSIZE', (0, 0), (-1, 0), 14),
  151. ('FONTSIZE', (0, 1), (-1, 1), 10),
  152. ('BOTTOMPADDING', (0, 0), (-1, -1), 10),
  153. ('TOPPADDING', (0, 0), (-1, -1), 10),
  154. ('GRID', (0, 0), (-1, -1), 2, colors.HexColor(grade_color))
  155. ]))
  156. story.append(grade_table)
  157. story.append(Spacer(1, 0.3*inch))
  158. # Add visualizations if available
  159. if include_visualizations and visualizations:
  160. story.append(PageBreak())
  161. story.append(Paragraph("Visualizations", heading_style))
  162. story.append(Spacer(1, 0.2*inch))
  163. for viz_idx, (viz_title, viz_pixmap) in enumerate(visualizations):
  164. # Scale pixmap to PDF size
  165. max_width_points = 4.5*inch
  166. max_height_points = 4*inch
  167. pixmap = viz_pixmap
  168. # Scale if necessary, maintaining aspect ratio
  169. if pixmap.width() > max_width_points or pixmap.height() > max_height_points:
  170. aspect_ratio = pixmap.width() / pixmap.height()
  171. # Scale to fit within bounds
  172. new_height = int(max_height_points)
  173. new_width = int(new_height * aspect_ratio)
  174. if new_width > max_width_points:
  175. new_width = int(max_width_points)
  176. new_height = int(new_width / aspect_ratio)
  177. pixmap = pixmap.scaled(new_width, new_height, 1, 1) # Qt.KeepAspectRatio, Qt.SmoothTransformation
  178. # Save pixmap to temporary image file
  179. temp_img_path = os.path.join(tempfile.gettempdir(), f"report_viz_{int(time.time()*1000000)}_{viz_idx}.png")
  180. pixmap.save(temp_img_path, "PNG")
  181. try:
  182. # Add to PDF with calculated dimensions to fit properly
  183. story.append(Paragraph(viz_title, heading_style))
  184. # Use width with height=None to let ReportLab calculate proportional height
  185. img = RLImage(temp_img_path, width=4*inch, height=None)
  186. story.append(img)
  187. story.append(Spacer(1, 0.2*inch))
  188. except Exception as e:
  189. print(f"Error adding image to PDF: {e}")
  190. # Build PDF
  191. doc.build(story)
  192. # Auto-open the PDF file
  193. try:
  194. if os.name == 'nt': # Windows
  195. os.startfile(file_path)
  196. elif os.name == 'posix': # macOS/Linux
  197. import subprocess
  198. subprocess.Popen(['open', file_path])
  199. except Exception:
  200. pass
  201. QMessageBox.information(
  202. parent_widget,
  203. "PDF Exported Successfully",
  204. f"Report exported to:\n{file_path}"
  205. )
  206. return True
  207. except Exception as e:
  208. QMessageBox.critical(
  209. parent_widget,
  210. "PDF Export Error",
  211. f"Error creating PDF:\n{str(e)}"
  212. )
  213. return False