radvisionai / report_utils.py
mgbam's picture
Update report_utils.py
eb87d5f verified
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