Spaces:
Restarting
Restarting
Update report_utils.py
Browse files- report_utils.py +51 -59
report_utils.py
CHANGED
@@ -1,9 +1,7 @@
|
|
1 |
-
# report_utils.py (Example filename)
|
2 |
-
|
3 |
import os
|
4 |
import logging
|
5 |
import tempfile
|
6 |
-
from fpdf import FPDF
|
7 |
from PIL import Image
|
8 |
from typing import Optional, Dict, Any
|
9 |
|
@@ -11,16 +9,19 @@ logger = logging.getLogger(__name__)
|
|
11 |
|
12 |
# Define constants for styling or fixed text
|
13 |
REPORT_TITLE = "MediVision QA - AI Analysis Report"
|
14 |
-
FOOTER_TEXT =
|
15 |
-
|
16 |
-
|
|
|
|
|
|
|
17 |
TEMP_IMAGE_PREFIX = "medivision_report_img_"
|
18 |
|
19 |
def generate_pdf_report_bytes(
|
20 |
session_id: str,
|
21 |
-
image: Optional[Image.Image],
|
22 |
analysis_outputs: Dict[str, str]
|
23 |
-
|
24 |
"""
|
25 |
Generates a PDF report summarizing the analysis session.
|
26 |
|
@@ -31,8 +32,8 @@ def generate_pdf_report_bytes(
|
|
31 |
session_id: The unique identifier for the analysis session.
|
32 |
image: A PIL Image object to embed in the report. If None, a
|
33 |
placeholder message is added.
|
34 |
-
analysis_outputs: A dictionary
|
35 |
-
|
36 |
|
37 |
Returns:
|
38 |
The generated PDF as bytes if successful, otherwise None.
|
@@ -41,7 +42,7 @@ def generate_pdf_report_bytes(
|
|
41 |
|
42 |
pdf = FPDF()
|
43 |
pdf.add_page()
|
44 |
-
pdf.set_margins(15, 15, 15)
|
45 |
pdf.set_auto_page_break(auto=True, margin=15)
|
46 |
|
47 |
# --- Header ---
|
@@ -49,120 +50,111 @@ def generate_pdf_report_bytes(
|
|
49 |
pdf.cell(0, 10, txt=REPORT_TITLE, ln=True, align='C')
|
50 |
pdf.set_font("Arial", size=10)
|
51 |
pdf.cell(0, 8, txt=f"Session ID: {session_id}", ln=True, align='C')
|
52 |
-
pdf.ln(10)
|
53 |
|
54 |
# --- Embed Image ---
|
55 |
-
temp_image_path = None
|
56 |
try:
|
57 |
if image is None:
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
else:
|
65 |
-
# Save image temporarily to
|
66 |
-
# Using delete=False requires manual cleanup in 'finally'
|
67 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".png", prefix=TEMP_IMAGE_PREFIX) as tmpfile:
|
68 |
temp_image_path = tmpfile.name
|
69 |
logger.debug(f"Saving temporary image for PDF report to: {temp_image_path}")
|
70 |
image.save(temp_image_path, format="PNG")
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
max_img_width_mm = page_width_mm * 0.9 # Allow some padding
|
76 |
|
77 |
img_width_px, img_height_px = image.size
|
78 |
if img_width_px <= 0 or img_height_px <= 0:
|
79 |
-
|
80 |
|
81 |
aspect_ratio = img_height_px / img_width_px
|
82 |
-
|
|
|
83 |
display_height_mm = display_width_mm * aspect_ratio
|
84 |
|
85 |
-
# Ensure image
|
86 |
-
available_height_mm = pdf.h - pdf.get_y() - pdf.b_margin - 10
|
87 |
if display_height_mm > available_height_mm:
|
88 |
-
logger.debug(f"Image height ({display_height_mm:.1f}mm) exceeds available space ({available_height_mm:.1f}mm)
|
89 |
display_height_mm = available_height_mm
|
90 |
-
display_width_mm = display_height_mm / aspect_ratio
|
91 |
|
92 |
-
# Center
|
93 |
x_pos = (pdf.w - display_width_mm) / 2
|
94 |
-
logger.debug(f"Embedding image:
|
95 |
pdf.image(temp_image_path, x=x_pos, y=pdf.get_y(), w=display_width_mm, h=display_height_mm)
|
96 |
-
pdf.ln(display_height_mm + 5)
|
97 |
|
98 |
except Exception as e:
|
99 |
err_msg = f"Error processing or embedding image in PDF: {e}"
|
100 |
logger.error(err_msg, exc_info=True)
|
101 |
-
# Add an error message within the PDF itself
|
102 |
pdf.set_text_color(*ERROR_COLOR)
|
103 |
pdf.set_font("Arial", 'B', 10)
|
104 |
pdf.multi_cell(0, 6, "[Error displaying image in report - See application logs for details]", align='C')
|
105 |
pdf.set_text_color(*DEFAULT_COLOR)
|
106 |
-
pdf.set_font("Arial", size=10)
|
107 |
pdf.ln(5)
|
108 |
finally:
|
109 |
-
#
|
110 |
if temp_image_path and os.path.exists(temp_image_path):
|
111 |
try:
|
112 |
os.remove(temp_image_path)
|
113 |
-
logger.debug(f"
|
114 |
except OSError as e:
|
115 |
-
# Log warning but don't crash PDF generation if removal fails
|
116 |
logger.warning(f"Could not remove temporary image file '{temp_image_path}': {e}")
|
117 |
|
118 |
-
# ---
|
119 |
pdf.set_font("Arial", 'B', 12)
|
120 |
pdf.cell(0, 10, txt="Analysis Results", ln=True)
|
121 |
|
122 |
if not analysis_outputs:
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
else:
|
127 |
for section_title, content in analysis_outputs.items():
|
128 |
# Section Title
|
129 |
pdf.set_font("Arial", 'B', 11)
|
130 |
-
# Use multi_cell for title in case it's long
|
131 |
pdf.multi_cell(0, 6, f"{section_title}:")
|
132 |
pdf.set_font("Arial", size=10)
|
133 |
|
134 |
-
# Section Content
|
135 |
content_to_write = content.strip() if content else "N/A"
|
136 |
try:
|
137 |
-
#
|
138 |
-
# handles unsupported characters gracefully for PDF generation.
|
139 |
encoded_content = content_to_write.encode('latin-1', 'replace').decode('latin-1')
|
140 |
pdf.multi_cell(0, 5, txt=encoded_content)
|
141 |
except Exception as e:
|
142 |
-
# Handle potential errors during string processing or writing
|
143 |
logger.error(f"Error writing section '{section_title}' to PDF: {e}", exc_info=True)
|
144 |
pdf.set_text_color(*ERROR_COLOR)
|
145 |
-
pdf.multi_cell(0, 5,
|
146 |
pdf.set_text_color(*DEFAULT_COLOR)
|
147 |
|
148 |
-
pdf.ln(4)
|
149 |
|
150 |
# --- Footer Disclaimer ---
|
151 |
-
pdf.set_y(-20)
|
152 |
pdf.set_font("Arial", 'I', 8)
|
153 |
-
pdf.set_text_color(128, 128, 128)
|
154 |
-
pdf.multi_cell(0, 4, txt=FOOTER_TEXT, align='C')
|
155 |
-
pdf.set_text_color(*DEFAULT_COLOR)
|
156 |
|
157 |
# --- Output PDF as Bytes ---
|
158 |
try:
|
159 |
-
# Output 'S' returns bytes (implicitly latin-1 encoded string first)
|
160 |
pdf_bytes = pdf.output(dest='S')
|
161 |
if isinstance(pdf_bytes, str):
|
162 |
-
|
163 |
-
pdf_bytes = pdf_bytes.encode('latin-1')
|
164 |
logger.info(f"PDF report ({len(pdf_bytes)} bytes) generated successfully for session {session_id}.")
|
165 |
return pdf_bytes
|
166 |
except Exception as e:
|
167 |
logger.error(f"Critical error during final PDF byte generation: {e}", exc_info=True)
|
168 |
-
return None
|
|
|
|
|
|
|
1 |
import os
|
2 |
import logging
|
3 |
import tempfile
|
4 |
+
from fpdf import FPDF # Recommend using fpdf2: pip install fpdf2
|
5 |
from PIL import Image
|
6 |
from typing import Optional, Dict, Any
|
7 |
|
|
|
9 |
|
10 |
# Define constants for styling or fixed text
|
11 |
REPORT_TITLE = "MediVision QA - AI Analysis Report"
|
12 |
+
FOOTER_TEXT = (
|
13 |
+
"Disclaimer: AI-generated analysis. For informational purposes only. "
|
14 |
+
"Requires expert clinical validation."
|
15 |
+
)
|
16 |
+
ERROR_COLOR = (255, 0, 0) # Red for errors in PDF
|
17 |
+
DEFAULT_COLOR = (0, 0, 0) # Black
|
18 |
TEMP_IMAGE_PREFIX = "medivision_report_img_"
|
19 |
|
20 |
def generate_pdf_report_bytes(
|
21 |
session_id: str,
|
22 |
+
image: Optional[Image.Image],
|
23 |
analysis_outputs: Dict[str, str]
|
24 |
+
) -> Optional[bytes]:
|
25 |
"""
|
26 |
Generates a PDF report summarizing the analysis session.
|
27 |
|
|
|
32 |
session_id: The unique identifier for the analysis session.
|
33 |
image: A PIL Image object to embed in the report. If None, a
|
34 |
placeholder message is added.
|
35 |
+
analysis_outputs: A dictionary with section titles as keys and
|
36 |
+
content strings as values.
|
37 |
|
38 |
Returns:
|
39 |
The generated PDF as bytes if successful, otherwise None.
|
|
|
42 |
|
43 |
pdf = FPDF()
|
44 |
pdf.add_page()
|
45 |
+
pdf.set_margins(15, 15, 15)
|
46 |
pdf.set_auto_page_break(auto=True, margin=15)
|
47 |
|
48 |
# --- Header ---
|
|
|
50 |
pdf.cell(0, 10, txt=REPORT_TITLE, ln=True, align='C')
|
51 |
pdf.set_font("Arial", size=10)
|
52 |
pdf.cell(0, 8, txt=f"Session ID: {session_id}", ln=True, align='C')
|
53 |
+
pdf.ln(10) # Space after header
|
54 |
|
55 |
# --- Embed Image ---
|
56 |
+
temp_image_path = None
|
57 |
try:
|
58 |
if image is None:
|
59 |
+
logger.warning("No image provided for PDF report.")
|
60 |
+
pdf.set_font("Arial", 'I', 10)
|
61 |
+
pdf.set_text_color(*ERROR_COLOR)
|
62 |
+
pdf.cell(0, 10, "[No image available for this report]", ln=True, align='C')
|
63 |
+
pdf.set_text_color(*DEFAULT_COLOR)
|
64 |
+
pdf.ln(5)
|
65 |
else:
|
66 |
+
# Save the image temporarily to a file for embedding
|
|
|
67 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".png", prefix=TEMP_IMAGE_PREFIX) as tmpfile:
|
68 |
temp_image_path = tmpfile.name
|
69 |
logger.debug(f"Saving temporary image for PDF report to: {temp_image_path}")
|
70 |
image.save(temp_image_path, format="PNG")
|
71 |
+
|
72 |
+
# Calculate display size: fit within 90% of page width
|
73 |
+
page_width_mm = pdf.w - pdf.l_margin - pdf.r_margin
|
74 |
+
max_img_width_mm = page_width_mm * 0.9
|
|
|
75 |
|
76 |
img_width_px, img_height_px = image.size
|
77 |
if img_width_px <= 0 or img_height_px <= 0:
|
78 |
+
raise ValueError(f"Invalid image dimensions: {image.size}")
|
79 |
|
80 |
aspect_ratio = img_height_px / img_width_px
|
81 |
+
# Use a basic heuristic: convert pixel dimensions to mm (adjustable)
|
82 |
+
display_width_mm = min(max_img_width_mm, img_width_px / 3)
|
83 |
display_height_mm = display_width_mm * aspect_ratio
|
84 |
|
85 |
+
# Ensure the image fits vertically on the page
|
86 |
+
available_height_mm = pdf.h - pdf.get_y() - pdf.b_margin - 10
|
87 |
if display_height_mm > available_height_mm:
|
88 |
+
logger.debug(f"Image height ({display_height_mm:.1f}mm) exceeds available space ({available_height_mm:.1f}mm). Resizing.")
|
89 |
display_height_mm = available_height_mm
|
90 |
+
display_width_mm = display_height_mm / aspect_ratio
|
91 |
|
92 |
+
# Center image horizontally
|
93 |
x_pos = (pdf.w - display_width_mm) / 2
|
94 |
+
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)")
|
95 |
pdf.image(temp_image_path, x=x_pos, y=pdf.get_y(), w=display_width_mm, h=display_height_mm)
|
96 |
+
pdf.ln(display_height_mm + 5)
|
97 |
|
98 |
except Exception as e:
|
99 |
err_msg = f"Error processing or embedding image in PDF: {e}"
|
100 |
logger.error(err_msg, exc_info=True)
|
|
|
101 |
pdf.set_text_color(*ERROR_COLOR)
|
102 |
pdf.set_font("Arial", 'B', 10)
|
103 |
pdf.multi_cell(0, 6, "[Error displaying image in report - See application logs for details]", align='C')
|
104 |
pdf.set_text_color(*DEFAULT_COLOR)
|
105 |
+
pdf.set_font("Arial", size=10)
|
106 |
pdf.ln(5)
|
107 |
finally:
|
108 |
+
# Ensure temporary image file is removed
|
109 |
if temp_image_path and os.path.exists(temp_image_path):
|
110 |
try:
|
111 |
os.remove(temp_image_path)
|
112 |
+
logger.debug(f"Removed temporary image file: {temp_image_path}")
|
113 |
except OSError as e:
|
|
|
114 |
logger.warning(f"Could not remove temporary image file '{temp_image_path}': {e}")
|
115 |
|
116 |
+
# --- Analysis Sections ---
|
117 |
pdf.set_font("Arial", 'B', 12)
|
118 |
pdf.cell(0, 10, txt="Analysis Results", ln=True)
|
119 |
|
120 |
if not analysis_outputs:
|
121 |
+
pdf.set_font("Arial", 'I', 10)
|
122 |
+
pdf.cell(0, 6, "No analysis results were generated for this session.", ln=True)
|
123 |
+
pdf.ln(4)
|
124 |
else:
|
125 |
for section_title, content in analysis_outputs.items():
|
126 |
# Section Title
|
127 |
pdf.set_font("Arial", 'B', 11)
|
|
|
128 |
pdf.multi_cell(0, 6, f"{section_title}:")
|
129 |
pdf.set_font("Arial", size=10)
|
130 |
|
|
|
131 |
content_to_write = content.strip() if content else "N/A"
|
132 |
try:
|
133 |
+
# Encode content using Latin-1 with replacement for unsupported characters
|
|
|
134 |
encoded_content = content_to_write.encode('latin-1', 'replace').decode('latin-1')
|
135 |
pdf.multi_cell(0, 5, txt=encoded_content)
|
136 |
except Exception as e:
|
|
|
137 |
logger.error(f"Error writing section '{section_title}' to PDF: {e}", exc_info=True)
|
138 |
pdf.set_text_color(*ERROR_COLOR)
|
139 |
+
pdf.multi_cell(0, 5, "[Error rendering content for this section - See application logs]")
|
140 |
pdf.set_text_color(*DEFAULT_COLOR)
|
141 |
|
142 |
+
pdf.ln(4) # Space between sections
|
143 |
|
144 |
# --- Footer Disclaimer ---
|
145 |
+
pdf.set_y(-20) # Position footer near the bottom
|
146 |
pdf.set_font("Arial", 'I', 8)
|
147 |
+
pdf.set_text_color(128, 128, 128) # Gray footer text
|
148 |
+
pdf.multi_cell(0, 4, txt=FOOTER_TEXT, align='C')
|
149 |
+
pdf.set_text_color(*DEFAULT_COLOR) # Reset text color
|
150 |
|
151 |
# --- Output PDF as Bytes ---
|
152 |
try:
|
|
|
153 |
pdf_bytes = pdf.output(dest='S')
|
154 |
if isinstance(pdf_bytes, str):
|
155 |
+
pdf_bytes = pdf_bytes.encode('latin-1')
|
|
|
156 |
logger.info(f"PDF report ({len(pdf_bytes)} bytes) generated successfully for session {session_id}.")
|
157 |
return pdf_bytes
|
158 |
except Exception as e:
|
159 |
logger.error(f"Critical error during final PDF byte generation: {e}", exc_info=True)
|
160 |
+
return None
|