Spaces:
Running
Running
import os | |
import logging | |
import tempfile | |
from fpdf import FPDF # Recommend using fpdf2: pip install fpdf2 | |
from PIL import Image | |
from typing import Optional, Dict, Any | |
logger = logging.getLogger(__name__) | |
# Define constants for styling or fixed text | |
REPORT_TITLE = "MediVision QA - AI Analysis Report" | |
FOOTER_TEXT = ( | |
"Disclaimer: AI-generated analysis. For informational purposes only. " | |
"Requires expert clinical validation." | |
) | |
ERROR_COLOR = (255, 0, 0) # Red for errors in PDF | |
DEFAULT_COLOR = (0, 0, 0) # Black | |
TEMP_IMAGE_PREFIX = "medivision_report_img_" | |
def generate_pdf_report_bytes( | |
session_id: str, | |
image: Optional[Image.Image], | |
analysis_outputs: Dict[str, str] | |
) -> Optional[bytes]: | |
""" | |
Generates a PDF report summarizing the analysis session. | |
Includes session ID, an embedded image (if provided), and formatted | |
analysis outputs. Handles errors during generation gracefully. | |
Args: | |
session_id: The unique identifier for the analysis session. | |
image: A PIL Image object to embed in the report. If None, a | |
placeholder message is added. | |
analysis_outputs: A dictionary with section titles as keys and | |
content strings as values. | |
Returns: | |
The generated PDF as bytes if successful, otherwise None. | |
""" | |
logger.info(f"Starting PDF report generation for session ID: {session_id}") | |
pdf = FPDF() | |
pdf.add_page() | |
pdf.set_margins(15, 15, 15) | |
pdf.set_auto_page_break(auto=True, margin=15) | |
# --- Header --- | |
pdf.set_font("Arial", 'B', 16) | |
pdf.cell(0, 10, txt=REPORT_TITLE, ln=True, align='C') | |
pdf.set_font("Arial", size=10) | |
pdf.cell(0, 8, txt=f"Session ID: {session_id}", ln=True, align='C') | |
pdf.ln(10) # Space after header | |
# --- Embed Image --- | |
temp_image_path = None | |
try: | |
if image is None: | |
logger.warning("No image provided for PDF report.") | |
pdf.set_font("Arial", 'I', 10) | |
pdf.set_text_color(*ERROR_COLOR) | |
pdf.cell(0, 10, "[No image available for this report]", ln=True, align='C') | |
pdf.set_text_color(*DEFAULT_COLOR) | |
pdf.ln(5) | |
else: | |
# Save the image temporarily to a file for embedding | |
with tempfile.NamedTemporaryFile(delete=False, suffix=".png", prefix=TEMP_IMAGE_PREFIX) as tmpfile: | |
temp_image_path = tmpfile.name | |
logger.debug(f"Saving temporary image for PDF report to: {temp_image_path}") | |
image.save(temp_image_path, format="PNG") | |
# Calculate display size: fit within 90% of page width | |
page_width_mm = pdf.w - pdf.l_margin - pdf.r_margin | |
max_img_width_mm = page_width_mm * 0.9 | |
img_width_px, img_height_px = image.size | |
if img_width_px <= 0 or img_height_px <= 0: | |
raise ValueError(f"Invalid image dimensions: {image.size}") | |
aspect_ratio = img_height_px / img_width_px | |
# Use a basic heuristic: convert pixel dimensions to mm (adjustable) | |
display_width_mm = min(max_img_width_mm, img_width_px / 3) | |
display_height_mm = display_width_mm * aspect_ratio | |
# Ensure the image fits vertically on the page | |
available_height_mm = pdf.h - pdf.get_y() - pdf.b_margin - 10 | |
if display_height_mm > available_height_mm: | |
logger.debug(f"Image height ({display_height_mm:.1f}mm) exceeds available space ({available_height_mm:.1f}mm). Resizing.") | |
display_height_mm = available_height_mm | |
display_width_mm = display_height_mm / aspect_ratio | |
# Center image horizontally | |
x_pos = (pdf.w - display_width_mm) / 2 | |
logger.debug(f"Embedding image: {os.path.basename(temp_image_path)} at position ({x_pos:.1f}, {pdf.get_y():.1f}) with size ({display_width_mm:.1f}x{display_height_mm:.1f} mm)") | |
pdf.image(temp_image_path, x=x_pos, y=pdf.get_y(), w=display_width_mm, h=display_height_mm) | |
pdf.ln(display_height_mm + 5) | |
except Exception as e: | |
err_msg = f"Error processing or embedding image in PDF: {e}" | |
logger.error(err_msg, exc_info=True) | |
pdf.set_text_color(*ERROR_COLOR) | |
pdf.set_font("Arial", 'B', 10) | |
pdf.multi_cell(0, 6, "[Error displaying image in report - See application logs for details]", align='C') | |
pdf.set_text_color(*DEFAULT_COLOR) | |
pdf.set_font("Arial", size=10) | |
pdf.ln(5) | |
finally: | |
# Ensure temporary image file is removed | |
if temp_image_path and os.path.exists(temp_image_path): | |
try: | |
os.remove(temp_image_path) | |
logger.debug(f"Removed temporary image file: {temp_image_path}") | |
except OSError as e: | |
logger.warning(f"Could not remove temporary image file '{temp_image_path}': {e}") | |
# --- Analysis Sections --- | |
pdf.set_font("Arial", 'B', 12) | |
pdf.cell(0, 10, txt="Analysis Results", ln=True) | |
if not analysis_outputs: | |
pdf.set_font("Arial", 'I', 10) | |
pdf.cell(0, 6, "No analysis results were generated for this session.", ln=True) | |
pdf.ln(4) | |
else: | |
for section_title, content in analysis_outputs.items(): | |
# Section Title | |
pdf.set_font("Arial", 'B', 11) | |
pdf.multi_cell(0, 6, f"{section_title}:") | |
pdf.set_font("Arial", size=10) | |
content_to_write = content.strip() if content else "N/A" | |
try: | |
# Encode content using Latin-1 with replacement for unsupported characters | |
encoded_content = content_to_write.encode('latin-1', 'replace').decode('latin-1') | |
pdf.multi_cell(0, 5, txt=encoded_content) | |
except Exception as e: | |
logger.error(f"Error writing section '{section_title}' to PDF: {e}", exc_info=True) | |
pdf.set_text_color(*ERROR_COLOR) | |
pdf.multi_cell(0, 5, "[Error rendering content for this section - See application logs]") | |
pdf.set_text_color(*DEFAULT_COLOR) | |
pdf.ln(4) # Space between sections | |
# --- Footer Disclaimer --- | |
pdf.set_y(-20) # Position footer near the bottom | |
pdf.set_font("Arial", 'I', 8) | |
pdf.set_text_color(128, 128, 128) # Gray footer text | |
pdf.multi_cell(0, 4, txt=FOOTER_TEXT, align='C') | |
pdf.set_text_color(*DEFAULT_COLOR) # Reset text color | |
# --- Output PDF as Bytes --- | |
try: | |
pdf_bytes = pdf.output(dest='S') | |
if isinstance(pdf_bytes, str): | |
pdf_bytes = pdf_bytes.encode('latin-1') | |
logger.info(f"PDF report ({len(pdf_bytes)} bytes) generated successfully for session {session_id}.") | |
return pdf_bytes | |
except Exception as e: | |
logger.error(f"Critical error during final PDF byte generation: {e}", exc_info=True) | |
return None | |