WilfredAyine commited on
Commit
1a40235
Β·
1 Parent(s): f95e0b4

feat: redesign backend pdf_generator.py to match premium Clinical Referral Card layout in ReportLab

Browse files
Files changed (1) hide show
  1. backend/api/pdf_generator.py +532 -216
backend/api/pdf_generator.py CHANGED
@@ -5,35 +5,59 @@ from datetime import datetime
5
  import qrcode
6
 
7
  from reportlab.lib.pagesizes import A4
8
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
9
  from reportlab.lib.units import cm
10
  from reportlab.lib import colors
11
  from reportlab.platypus import (
12
  SimpleDocTemplate, Paragraph, Spacer,
13
  Table, TableStyle, Image as RLImage, HRFlowable
14
  )
15
- from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
16
 
17
  PAGE_W, PAGE_H = A4
18
- MARGIN = 2 * cm
19
 
20
- # DermaDetect Branding Colors
21
- C_BLUE = colors.HexColor('#0077b6')
22
- LIGHT_BLUE = colors.HexColor('#e0f2fe')
23
- DARK = colors.HexColor('#181c1e')
24
- GRAY = colors.HexColor('#555f71')
25
- LIGHT_GRAY = colors.HexColor('#bccac1')
 
 
 
 
26
 
 
 
 
 
27
 
28
- def _markdown_to_rl(text: str) -> str:
29
- """Very basic markdown to ReportLab HTML conversion."""
 
 
 
 
 
 
 
 
30
  if not text:
31
- return ""
32
- # Convert **bold** to <b>bold</b>
33
  text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text)
34
- # Convert newlines
35
- text = text.replace('\n', '<br/>')
36
- return text
 
 
 
 
 
 
 
 
 
37
 
38
 
39
  def build_pdf(
@@ -42,236 +66,528 @@ def build_pdf(
42
  clinical: dict,
43
  images: dict
44
  ) -> str:
45
- """
46
- Builds the DermaDetect PDF report.
47
- Returns base64-encoded PDF string.
48
- """
49
  buf = io.BytesIO()
50
  doc = SimpleDocTemplate(
51
- buf,
52
- pagesize=A4,
53
  leftMargin=MARGIN, rightMargin=MARGIN,
54
- topMargin=MARGIN, bottomMargin=MARGIN,
55
  )
 
 
56
 
57
- styles = _build_styles()
58
- story = []
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
- # ── Header ──────────────────────────────────────────────────────
61
- story.append(Paragraph("DermaDetect", styles['title']))
62
- story.append(Paragraph("AI-Assisted Dermatology Analysis Report", styles['subtitle']))
63
- story.append(Paragraph(
64
- f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')} | Case ID: {case_id}",
65
- styles['meta']
66
- ))
67
- story.append(HRFlowable(width="100%", thickness=1, color=C_BLUE, spaceAfter=12))
 
 
 
 
68
 
69
- # ── Patient Context ──────────────────────────────────────────────
70
- p_name = patient.get('name', 'Unknown Patient')
71
- p_age = patient.get('age', 'N/A')
72
- p_sex = patient.get('sex', 'N/A')
73
- story.append(Paragraph(f"<b>Patient:</b> {p_name} | <b>Age:</b> {p_age} | <b>Sex:</b> {p_sex}", styles['body']))
74
- story.append(Spacer(1, 12))
75
 
76
- # ── Prediction summary table ─────────────────────────────────────
77
- story.append(Paragraph("AI Triage Summary", styles['section']))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  story.append(Spacer(1, 6))
79
 
80
- primary_finding = clinical.get('primaryFinding', 'Unknown')
81
- confidence = clinical.get('confidence', 0)
82
- urgency = clinical.get('urgency', 'Unknown')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- summary_data = [
85
- ['Primary Finding', 'Confidence', 'Urgency Level'],
86
- [primary_finding, f"{confidence}%", urgency.upper()],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  ]
88
 
89
- table = Table(summary_data, colWidths=[7*cm, 4*cm, 5*cm])
90
- table.setStyle(TableStyle([
91
- ('BACKGROUND', (0,0), (-1,0), C_BLUE),
92
- ('TEXTCOLOR', (0,0), (-1,0), colors.white),
93
- ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
94
- ('FONTSIZE', (0,0), (-1,0), 10),
95
- ('BACKGROUND', (0,1), (-1,-1), LIGHT_BLUE),
96
- ('FONTSIZE', (0,1), (-1,-1), 10),
97
- ('ALIGN', (0,0), (-1,-1), 'CENTER'),
98
- ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
99
- ('ROWHEIGHT', (0,0), (-1,-1), 22),
100
- ('GRID', (0,0), (-1,-1), 0.5, C_BLUE),
101
  ]))
102
- story.append(table)
103
- story.append(Spacer(1, 16))
104
 
105
- # ── Images ──────────────────────────────────────────────────────
106
- story.append(Paragraph("Visual Analysis", styles['section']))
107
- story.append(Spacer(1, 6))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- img_row = []
110
- img_labels = []
111
-
112
- for key, label in [
113
- ('original_b64', 'Original Lesion'),
114
- ('heatmap_b64', 'AI Saliency Map'),
115
- ]:
116
- b64 = images.get(key)
117
- if b64:
118
- # Strip data URI scheme if present
119
- if b64.startswith("data:"):
120
- b64 = b64.split(",")[1]
121
- try:
122
- img_buf = io.BytesIO(base64.b64decode(b64))
123
- rl_img = RLImage(img_buf, width=6*cm, height=6*cm)
124
- img_row.append(rl_img)
125
- img_labels.append(label)
126
- except Exception:
127
- pass
128
-
129
- if img_row:
130
- img_table = Table(
131
- [img_row, [Paragraph(l, styles['img_label']) for l in img_labels]],
132
- colWidths=[6.5*cm] * len(img_row)
133
- )
134
- img_table.setStyle(TableStyle([
135
- ('ALIGN', (0,0), (-1,-1), 'CENTER'),
136
- ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
137
  ]))
138
- story.append(img_table)
139
- story.append(Spacer(1, 16))
140
 
141
- # ── Clinical report sections ──────────────────────────────────────
142
- story.append(HRFlowable(width="100%", thickness=0.5, color=LIGHT_GRAY, spaceAfter=8))
143
- story.append(Paragraph("AI Clinical Evaluation", styles['section']))
144
- story.append(Spacer(1, 6))
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
- referral_note = clinical.get('referralNote', '')
147
- if referral_note:
148
- html_note = _markdown_to_rl(referral_note)
149
- for part in html_note.split('<br/>'):
150
- if part.strip():
151
- story.append(Paragraph(part.strip(), styles['body']))
152
- story.append(Spacer(1, 4))
153
-
154
- story.append(Spacer(1, 12))
155
 
156
- # Treatment Notes (Legacy)
157
- t_notes = clinical.get('treatmentNotes', [])
158
  if t_notes:
159
- story.append(Paragraph("Treatment Action Plan", styles['subsection']))
160
- for note in t_notes:
161
- story.append(Paragraph(f"β€’ {note}", styles['body_bullet']))
162
- story.append(Spacer(1, 8))
163
-
164
- # Therapy Regimen
165
- regimen = clinical.get('therapyRegimen', {})
166
- if regimen:
167
- story.append(Paragraph("Prescribed Therapy Regimen", styles['subsection']))
168
- story.append(Paragraph(f"<b>Medication:</b> {regimen.get('medication', 'N/A')}", styles['body']))
169
- story.append(Paragraph(f"<b>Dosage:</b> {regimen.get('dosage', 'N/A')}", styles['body']))
170
- story.append(Paragraph(f"<b>Duration:</b> {regimen.get('duration', 'N/A')}", styles['body']))
171
- story.append(Paragraph(f"<b>Instructions:</b> {regimen.get('instructions', 'N/A')}", styles['body']))
172
- story.append(Spacer(1, 8))
173
-
174
- # Patient Handout (Dos and Don'ts)
175
- handout = clinical.get('patientHandout', {})
176
- if handout:
177
- story.append(Paragraph("Patient Handout", styles['subsection']))
178
- dos = handout.get('dos', [])
179
- donts = handout.get('donts', [])
180
- if dos:
181
- story.append(Paragraph("<b>Do's:</b>", styles['body']))
182
- for item in dos:
183
- story.append(Paragraph(f"β€’ {item}", styles['body_bullet']))
184
- if donts:
185
- story.append(Paragraph("<b>Don'ts:</b>", styles['body']))
186
- for item in donts:
187
- story.append(Paragraph(f"β€’ {item}", styles['body_bullet']))
188
- story.append(Spacer(1, 8))
189
-
190
- # Recommended Action
191
- action = clinical.get('recommendedAction', '')
192
- if action:
193
- story.append(Paragraph("Recommended Action", styles['subsection']))
194
- story.append(Paragraph(action, styles['body']))
195
- story.append(Spacer(1, 8))
196
-
197
- # ── Footer / Signature ─────────────────────────────────────────────
198
- # Push to bottom visually
199
- story.append(Spacer(1, 24))
200
- story.append(HRFlowable(width="100%", thickness=0.5, color=LIGHT_GRAY, spaceBefore=12, spaceAfter=12))
201
-
202
- # Generate QR Code
203
- qr = qrcode.QRCode(box_size=3, border=1)
204
- qr.add_data(f"https://secure.dermadetect.app/verify/{case_id}")
205
- qr.make(fit=True)
206
- qr_img = qr.make_image(fill_color="black", back_color="white")
207
-
208
- qr_buf = io.BytesIO()
209
- qr_img.save(qr_buf, format="PNG")
210
- qr_buf.seek(0)
211
- rl_qr = RLImage(qr_buf, width=2.5*cm, height=2.5*cm)
212
-
213
- hw_name = patient.get('healthWorkerName') or 'K. Mensah'
214
-
215
- sig_cell = Paragraph(
216
- f"<font color='#555f71' size=8><i>Digital Verified Signature</i></font><br/><br/>"
217
- f"<font size=14><b>{hw_name}</b></font><br/>"
218
- f"<font color='#181c1e'>________________________</font>",
219
- styles['body']
220
  )
221
-
222
- qr_text = Paragraph(
223
- "<font size=7 color='#3d4943'><b>Verify on<br/>DermaDetect Secure<br/>Web Cloud</b></font>",
224
- styles['body']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  )
226
-
227
- sig_table = Table([[sig_cell, rl_qr, qr_text]], colWidths=[10*cm, 3*cm, 3.5*cm])
228
- sig_table.setStyle(TableStyle([
229
- ('ALIGN', (0,0), (0,0), 'LEFT'),
230
- ('ALIGN', (1,0), (2,0), 'RIGHT'),
231
- ('VALIGN', (0,0), (-1,-1), 'BOTTOM'),
 
 
 
 
 
 
 
232
  ]))
233
- story.append(sig_table)
234
-
235
- story.append(Spacer(1, 12))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  story.append(Paragraph(
237
- "Disclaimer: This assessment is AI-assisted using the advanced <b>DermaVision</b> language model "
238
- "developed by <b>PreWorks</b>. It is intended to support, not replace, clinical judgment "
239
- "by a qualified healthcare professional.",
240
- styles['disclaimer']
241
  ))
242
 
243
  doc.build(story)
244
  return base64.b64encode(buf.getvalue()).decode('utf-8')
245
 
246
 
247
- def _build_styles() -> dict:
248
- base = getSampleStyleSheet()
249
  return {
250
- 'title': ParagraphStyle('title',
251
- fontSize=22, fontName='Helvetica-Bold',
252
- textColor=C_BLUE, alignment=TA_CENTER, spaceAfter=4, leading=26),
253
- 'subtitle': ParagraphStyle('subtitle',
254
- fontSize=11, fontName='Helvetica',
255
- textColor=DARK, alignment=TA_CENTER, spaceAfter=2, leading=14),
256
- 'meta': ParagraphStyle('meta',
257
- fontSize=8, fontName='Helvetica',
258
- textColor=GRAY, alignment=TA_CENTER, spaceAfter=10, leading=10),
259
- 'section': ParagraphStyle('section',
260
- fontSize=13, fontName='Helvetica-Bold',
261
- textColor=C_BLUE, spaceAfter=8, leading=16),
262
- 'subsection': ParagraphStyle('subsection',
263
- fontSize=11, fontName='Helvetica-Bold',
264
- textColor=DARK, spaceAfter=4, leading=14),
265
- 'body': ParagraphStyle('body',
266
- fontSize=10, fontName='Helvetica',
267
- textColor=DARK, leading=14, alignment=TA_LEFT),
268
- 'body_bullet': ParagraphStyle('body_bullet',
269
- fontSize=10, fontName='Helvetica',
270
- textColor=DARK, leading=14, alignment=TA_LEFT, leftIndent=12),
271
- 'img_label': ParagraphStyle('img_label',
272
- fontSize=8, fontName='Helvetica-Bold',
273
- textColor=GRAY, alignment=TA_CENTER),
274
- 'disclaimer': ParagraphStyle('disclaimer',
275
- fontSize=8, fontName='Helvetica',
276
- textColor=GRAY, alignment=TA_CENTER),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  }
 
5
  import qrcode
6
 
7
  from reportlab.lib.pagesizes import A4
8
+ from reportlab.lib.styles import ParagraphStyle
9
  from reportlab.lib.units import cm
10
  from reportlab.lib import colors
11
  from reportlab.platypus import (
12
  SimpleDocTemplate, Paragraph, Spacer,
13
  Table, TableStyle, Image as RLImage, HRFlowable
14
  )
15
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
16
 
17
  PAGE_W, PAGE_H = A4
18
+ MARGIN = 1.0 * cm
19
 
20
+ # ── Brand palette ─────────────────────────────────────────────────────────────
21
+ NAVY = colors.HexColor('#0A3369') # DermaDetect navy #0a3369
22
+ TEAL = colors.HexColor('#0D9488') # Teal headers
23
+ DARK = colors.HexColor('#0F172A') # Slate-900
24
+ GRAY = colors.HexColor('#64748B') # Slate-500
25
+ MID_GRAY = colors.HexColor('#CBD5E1') # Slate-300
26
+ LIGHT_BLUE = colors.HexColor('#EBF5FB')
27
+ LIGHT_TEAL = colors.HexColor('#F0FDFA') # Light teal box back
28
+ BORDER_TEAL = colors.HexColor('#CCFBF1') # Teal-100 border
29
+ WHITE = colors.white
30
 
31
+ C_HIGH = colors.HexColor('#D85A30')
32
+ C_MOD = colors.HexColor('#EF9F27')
33
+ C_LOW = colors.HexColor('#082F49')
34
+ C_OK = colors.HexColor('#10B981') # Green Analysis OK badge
35
 
36
+
37
+ def _uc(urgency: str) -> colors.Color:
38
+ u = urgency.lower() if urgency else 'low'
39
+ if 'high' in u: return C_HIGH
40
+ if 'mod' in u: return C_MOD
41
+ return C_LOW
42
+
43
+
44
+ def _md(text: str) -> str:
45
+ """Minimal markdown β†’ ReportLab HTML."""
46
  if not text:
47
+ return ''
 
48
  text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text)
49
+ return text.replace('\n', '<br/>')
50
+
51
+
52
+ def _img(b64: str, w: float, h: float):
53
+ if not b64:
54
+ return None
55
+ if b64.startswith('data:'):
56
+ b64 = b64.split(',')[1]
57
+ try:
58
+ return RLImage(io.BytesIO(base64.b64decode(b64)), width=w, height=h)
59
+ except Exception:
60
+ return None
61
 
62
 
63
  def build_pdf(
 
66
  clinical: dict,
67
  images: dict
68
  ) -> str:
 
 
 
 
69
  buf = io.BytesIO()
70
  doc = SimpleDocTemplate(
71
+ buf, pagesize=A4,
 
72
  leftMargin=MARGIN, rightMargin=MARGIN,
73
+ topMargin=0.8 * cm, bottomMargin=0.8 * cm,
74
  )
75
+ S = _styles()
76
+ story = []
77
 
78
+ # ── Unpack data ───────────────────────────────────────────────────────────
79
+ p_name = patient.get('name') or 'Unknown Patient'
80
+ p_contact = patient.get('contactNumber') or patient.get('contact') or 'β€”'
81
+ p_age = patient.get('age') or 'β€”'
82
+ p_age_str = f"{p_age} years" if str(p_age).isdigit() else str(p_age)
83
+ p_sex = patient.get('sex') or 'β€”'
84
+ p_notes = patient.get('symptoms') or patient.get('notes') or 'β€”'
85
+ p_date = datetime.utcnow().strftime('%d %b %Y')
86
+
87
+ hw_name = patient.get('healthWorkerName') or patient.get('clinicianName') or 'Akosua Darko'
88
+ hw_role = patient.get('role') or patient.get('clinicianRole') or 'Dermatology Specialist'
89
+ hw_facility = patient.get('facilityName') or patient.get('clinicianFacility') or 'Atonsu community'
90
+ hw_district = patient.get('district') or 'Kumasi'
91
+ hw_region = patient.get('region') or 'Greater Accra Region'
92
+ hw_contact = patient.get('contact') or '0243000000'
93
 
94
+ primary_finding = clinical.get('primaryFinding') or 'Vascular Tumors'
95
+ confidence = clinical.get('confidence', 100)
96
+ try:
97
+ c_val = float(confidence)
98
+ if c_val <= 1.0:
99
+ c_val = c_val * 100
100
+ except Exception:
101
+ c_val = 100.0
102
+
103
+ urgency = clinical.get('urgency', 'Moderate')
104
+ urgency_text = clinical.get('referralNote') or 'Refer to clinic within 3 days for assessment and treatment.'
105
+ accent = _uc(urgency)
106
 
107
+ regimen = clinical.get('therapyRegimen') or {}
108
+ med_name = regimen.get('medication') or 'None Prescribed'
109
+ dosage_str = regimen.get('instructions') or regimen.get('dosage') or 'No directions specified.'
 
 
 
110
 
111
+ t_notes = clinical.get('treatmentNotes') or []
112
+
113
+ AW = PAGE_W - 2 * MARGIN # available width = 19.0 cm
114
+ LW = 7.2 * cm # left column
115
+ GW = 0.4 * cm # gap
116
+ RW = AW - LW - GW # right column = 11.4 cm
117
+
118
+ # ═══════════════════════════════════════════════════════════════════════════
119
+ # 1. HEADER BAR
120
+ # ═══════════════════════════════════════════════════════════════════════════
121
+ hdr = Table(
122
+ [[
123
+ Paragraph(
124
+ '<font color="white" size=14><b>DermaDetect</b></font><br/>'
125
+ '<font color="#A8D8EA" size=7>AI-Powered Skin Assessment</font>',
126
+ S['left_white']
127
+ ),
128
+ Paragraph(
129
+ '<font color="white" size=11><b>CLINICAL REFERRAL NOTE</b></font><br/>'
130
+ f'<font color="#A8D8EA" size=7>REF: {case_id}</font>',
131
+ S['right_white']
132
+ ),
133
+ ]],
134
+ colWidths=[AW * 0.5, AW * 0.5]
135
+ )
136
+ hdr.setStyle(TableStyle([
137
+ ('BACKGROUND', (0, 0), (-1, -1), NAVY),
138
+ ('ALIGN', (1, 0), (1, 0), 'RIGHT'),
139
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
140
+ ('TOPPADDING', (0, 0), (-1, -1), 10),
141
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 10),
142
+ ('LEFTPADDING', (0, 0), (-1, -1), 14),
143
+ ('RIGHTPADDING', (0, 0), (-1, -1), 14),
144
+ ]))
145
+ story.append(hdr)
146
+ story.append(Spacer(1, 4))
147
+
148
+ # ═══════════════════════════════════════════════════════════════════════════
149
+ # 2. URGENCY BANNER
150
+ # ═══════════════════════════════════════════════════════════════════════════
151
+ banner = Table(
152
+ [[Paragraph(
153
+ f'<font color="white"><b>● {urgency.upper()} β€” {urgency_text}</b></font>',
154
+ S['center_white']
155
+ )]],
156
+ colWidths=[AW]
157
+ )
158
+ banner.setStyle(TableStyle([
159
+ ('BACKGROUND', (0, 0), (-1, -1), accent),
160
+ ('TOPPADDING', (0, 0), (-1, -1), 7),
161
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 7),
162
+ ('LEFTPADDING', (0, 0), (-1, -1), 10),
163
+ ('RIGHTPADDING', (0, 0), (-1, -1), 10),
164
+ ]))
165
+ story.append(banner)
166
  story.append(Spacer(1, 6))
167
 
168
+ # ═══════════════════════════════════════════════════════════════════════════
169
+ # 3. TWO-COLUMN BODY
170
+ # ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ # ── LEFT COLUMN ──────────────────────────────────────────────────────────
173
+ def info_tbl(rows: list) -> Table:
174
+ t = Table(rows, colWidths=[2.5 * cm, LW - 2.5 * cm])
175
+ t.setStyle(TableStyle([
176
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
177
+ ('TOPPADDING', (0, 0), (-1, -1), 1.5),
178
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 1.5),
179
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
180
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
181
+ ]))
182
+ return t
183
+
184
+ def lbl(text): return Paragraph(text, S['info_lbl'])
185
+ def val(text): return Paragraph(str(text), S['info_val'])
186
+ def sec(text): return Paragraph(text, S['sec_head'])
187
+
188
+ L = []
189
+
190
+ # Patient Information
191
+ L += [
192
+ [sec('PATIENT INFORMATION')],
193
+ [info_tbl([
194
+ [lbl('Full Name'), val(p_name)],
195
+ [lbl('Contact Number'), val(p_contact)],
196
+ [lbl('Age'), val(p_age_str)],
197
+ [lbl('Sex'), val(p_sex)],
198
+ [lbl('Date of Visit'), val(p_date)],
199
+ [lbl('Patient ID'), val(case_id)],
200
+ ])],
201
+ [Spacer(1, 4)],
202
+ ]
203
+
204
+ # Referring Health Worker
205
+ L += [
206
+ [sec('REFERRING HEALTH WORKER')],
207
+ [info_tbl([
208
+ [lbl('Name'), val(hw_name)],
209
+ [lbl('Role'), val(hw_role)],
210
+ [lbl('Facility Name'), val(hw_facility)],
211
+ [lbl('District'), val(hw_district)],
212
+ [lbl('Region'), val(hw_region)],
213
+ [lbl('Contact'), val(hw_contact)],
214
+ ])],
215
+ [Spacer(1, 4)],
216
+ ]
217
+
218
+ # Refer To
219
+ L += [
220
+ [sec('REFER TO')],
221
+ [info_tbl([
222
+ [lbl('Facility Type'), val('District Hospital / Dermatology Clinic')],
223
+ [lbl('Department'), val('Dermatology / General OPD')],
224
+ [lbl('Urgency'), Paragraph(f'Within { "3 days" if urgency.lower() == "moderate" else "immediate" }',
225
+ ParagraphStyle('uv', parent=S['info_val'],
226
+ textColor=accent))],
227
+ ])],
228
+ [Spacer(1, 4)],
229
+ ]
230
+
231
+ # Health Worker's Notes
232
+ L += [
233
+ [sec("HEALTH WORKER'S NOTES")],
234
+ [Table(
235
+ [[Paragraph(f'<i>"{p_notes}"</i>', S['notes'])]],
236
+ colWidths=[LW],
237
+ style=[
238
+ ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#F8FAFC')),
239
+ ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#E2E8F0')),
240
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
241
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
242
+ ('LEFTPADDING', (0, 0), (-1, -1), 8),
243
+ ('RIGHTPADDING', (0, 0), (-1, -1), 8),
244
+ ]
245
+ )],
246
+ [Spacer(1, 4)],
247
+ ]
248
 
249
+ # Recommended Medications
250
+ med_inner = Table(
251
+ [
252
+ [Paragraph('<b>PRESCRIBED MEDICATION</b>', S['box_lbl'])],
253
+ [Paragraph(med_name, S['box_val'])],
254
+ [Spacer(1, 3)],
255
+ [Paragraph('<b>DOSAGE REGIMEN / DIRECTIONS</b>', S['box_lbl'])],
256
+ [Paragraph(dosage_str, S['box_val'])],
257
+ ],
258
+ colWidths=[LW - 0.6 * cm]
259
+ )
260
+ med_inner.setStyle(TableStyle([
261
+ ('TOPPADDING', (0, 0), (-1, -1), 1),
262
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 1),
263
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
264
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
265
+ ]))
266
+ med_box = Table([[med_inner]], colWidths=[LW])
267
+ med_box.setStyle(TableStyle([
268
+ ('BACKGROUND', (0, 0), (-1, -1), LIGHT_TEAL),
269
+ ('BOX', (0, 0), (-1, -1), 0.5, BORDER_TEAL),
270
+ ('LEFTPADDING', (0, 0), (-1, -1), 8),
271
+ ('RIGHTPADDING', (0, 0), (-1, -1), 8),
272
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
273
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
274
+ ]))
275
+ L += [
276
+ [sec('RECOMMENDED MEDICATIONS')],
277
+ [med_box],
278
  ]
279
 
280
+ left_col = Table(L, colWidths=[LW])
281
+ left_col.setStyle(TableStyle([
282
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
283
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
284
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
285
+ ('TOPPADDING', (0, 0), (-1, -1), 0),
286
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
 
 
 
 
 
287
  ]))
 
 
288
 
289
+ # ── RIGHT COLUMN ──────────────────────────────────────────────────────────
290
+ R = []
291
+ BW = RW - 0.3 * cm # bar width
292
+
293
+ # AI Assessment header + ANALYSIS OK badge
294
+ ok = Table(
295
+ [[Paragraph('<font color="white" size=7><b>ANALYSIS OK</b></font>', S['center_white'])]],
296
+ colWidths=[2.3 * cm]
297
+ )
298
+ ok.setStyle(TableStyle([
299
+ ('BACKGROUND', (0, 0), (-1, -1), C_OK),
300
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
301
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3),
302
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
303
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
304
+ ]))
305
+ ai_hdr = Table([[sec('AI ASSESSMENT'), ok]], colWidths=[RW - 2.6 * cm, 2.6 * cm])
306
+ ai_hdr.setStyle(TableStyle([
307
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
308
+ ('ALIGN', (1, 0), (1, 0), 'RIGHT'),
309
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
310
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
311
+ ('TOPPADDING', (0, 0), (-1, -1), 0),
312
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
313
+ ]))
314
+ R += [[ai_hdr], [Spacer(1, 4)]]
315
 
316
+ # Finding Title
317
+ R += [[Paragraph(primary_finding, S['finding'])], [Spacer(1, 4)]]
318
+
319
+ # Confidence bar
320
+ R.append([Paragraph(
321
+ f'<font size=8 color="#64748B">Detection confidence</font>'
322
+ f'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
323
+ f'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
324
+ f'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
325
+ f'<font size=9 color="#0A1628"><b>{c_val:.0f}%</b></font>',
326
+ S['bar_lbl']
327
+ )])
328
+ conf_ratio = c_val / 100.0
329
+ fw = BW * conf_ratio
330
+ ew = BW * (1.0 - conf_ratio)
331
+ if ew > 0.01:
332
+ bar = Table([[None, None]], colWidths=[fw, ew])
333
+ bar.setStyle(TableStyle([
334
+ ('BACKGROUND', (0, 0), (0, 0), NAVY),
335
+ ('BACKGROUND', (1, 0), (1, 0), MID_GRAY),
336
+ ('ROWHEIGHT', (0, 0), (-1, -1), 7),
337
+ ]))
338
+ else:
339
+ bar = Table([[None]], colWidths=[BW])
340
+ bar.setStyle(TableStyle([
341
+ ('BACKGROUND', (0, 0), (0, 0), NAVY),
342
+ ('ROWHEIGHT', (0, 0), (-1, -1), 7),
 
343
  ]))
344
+ R += [[bar], [Spacer(1, 4)]]
 
345
 
346
+ # Urgency pill
347
+ pill = Table(
348
+ [[Paragraph(
349
+ f'<font color="white" size=8><b>{urgency.upper()} URGENCY</b></font>',
350
+ S['center_white']
351
+ )]],
352
+ colWidths=[3.2 * cm]
353
+ )
354
+ pill.setStyle(TableStyle([
355
+ ('BACKGROUND', (0, 0), (-1, -1), accent),
356
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
357
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
358
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
359
+ ('RIGHTPADDING', (0, 0), (-1, -1), 6),
360
+ ]))
361
+ R += [[pill], [Spacer(1, 4)]]
362
 
363
+ # Assessment narrative text
364
+ assessment_narrative = (
365
+ f"A potential clinical skin indication detected by the assistive triage scanner. "
366
+ f"Standard clinical diagnostic procedures are recommended before commencing definitive therapy."
367
+ )
368
+ R += [[Paragraph(assessment_narrative, S['findings'])], [Spacer(1, 4)]]
 
 
 
369
 
370
+ # Suggested Treatment
 
371
  if t_notes:
372
+ R.append([Paragraph('SUGGESTED TREATMENT', S['sub_head'])])
373
+ for note in t_notes[:3]:
374
+ R.append([Paragraph(f'β€’ {note}', S['bullet'])])
375
+ R.append([Spacer(1, 4)])
376
+
377
+ # Disclaimer
378
+ disc = "This is an AI-generated suggestion. Final treatment decisions rest with the clinician."
379
+ R += [[Paragraph(f'<i>{disc}</i>', S['disc_sm'])], [Spacer(1, 4)]]
380
+
381
+ # Visual Analysis (2 images side-by-side)
382
+ R.append([Paragraph('PHOTO TAKEN DURING ASSESSMENT', S['sub_head'])])
383
+ R.append([Spacer(1, 4)])
384
+
385
+ IW = (RW - 0.4 * cm) / 2
386
+ IH = 4.2 * cm
387
+
388
+ cells = []
389
+ labels = []
390
+
391
+ im1 = _img(images.get('original_b64'), IW, IH)
392
+ cells.append(im1 if im1 else Paragraph('β€”', S['center']))
393
+ labels.append(Paragraph('Clinical Specimen', S['img_lbl']))
394
+
395
+ im2 = _img(images.get('heatmap_b64'), IW, IH)
396
+ cells.append(im2 if im2 else Paragraph('β€”', S['center']))
397
+ labels.append(Paragraph('AI Saliency Map', S['img_lbl']))
398
+
399
+ img_table = Table([cells, labels], colWidths=[IW + 0.2 * cm, IW + 0.2 * cm])
400
+ img_table.setStyle(TableStyle([
401
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
402
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
403
+ ('TOPPADDING', (0, 0), (-1, -1), 1),
404
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 1),
405
+ ]))
406
+ R.append([img_table])
407
+
408
+ right_col = Table(R, colWidths=[RW])
409
+ right_col.setStyle(TableStyle([
410
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
411
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
412
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
413
+ ('TOPPADDING', (0, 0), (-1, -1), 0),
414
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
415
+ ]))
416
+
417
+ # Assemble
418
+ body = Table(
419
+ [[left_col, Spacer(GW, 1), right_col]],
420
+ colWidths=[LW, GW, RW]
 
 
 
 
 
 
 
 
 
 
 
 
421
  )
422
+ body.setStyle(TableStyle([
423
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
424
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
425
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
426
+ ('TOPPADDING', (0, 0), (-1, -1), 0),
427
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
428
+ ]))
429
+ story.append(body)
430
+ story.append(Spacer(1, 6))
431
+ story.append(HRFlowable(width='100%', thickness=0.5, color=MID_GRAY))
432
+ story.append(Spacer(1, 4))
433
+
434
+ # ═══════════════════════════════════════════════════════════════════════════
435
+ # 4. SIGNATURE ROW
436
+ # ═══════════════════════════════════════════════════════════════════════════
437
+ SLW = AW * 0.55
438
+ SRW = AW * 0.45
439
+
440
+ sig_left = Table(
441
+ [
442
+ [Paragraph('<font color="#64748B" size=7><i>HEALTH WORKER SIGNATURE</i></font>', S['left'])],
443
+ [Spacer(1, 4)],
444
+ [Paragraph(f'<b>{hw_name}</b>', S['sig_name'])],
445
+ [Paragraph(f'<font color="#64748B">{hw_role} - {hw_facility}</font>', S['left_sm'])],
446
+ [Paragraph(f'Date: {p_date}', S['left_sm'])],
447
+ ],
448
+ colWidths=[SLW]
449
  )
450
+
451
+ stamp = Table(
452
+ [[Paragraph(
453
+ '<font color="#94A3B8" size=7>PLACE CLINICAL STAMP HERE</font>',
454
+ S['center']
455
+ )]],
456
+ colWidths=[SRW - 0.5 * cm],
457
+ rowHeights=[2.2 * cm]
458
+ )
459
+ stamp.setStyle(TableStyle([
460
+ ('BOX', (0, 0), (-1, -1), 0.5, MID_GRAY),
461
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
462
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
463
  ]))
464
+ sig_right_wrap = Table([
465
+ [stamp],
466
+ [Spacer(1, 2)],
467
+ [Paragraph('<font color="#64748B" size=6><i>* To be completed at receiving facility</i></font>', S['right'])]
468
+ ], colWidths=[SRW])
469
+ sig_right_wrap.setStyle(TableStyle([
470
+ ('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
471
+ ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'),
472
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
473
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
474
+ ]))
475
+
476
+ sig_row = Table([[sig_left, sig_right_wrap]], colWidths=[SLW, SRW])
477
+ sig_row.setStyle(TableStyle([
478
+ ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'),
479
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
480
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
481
+ ('TOPPADDING', (0, 0), (-1, -1), 0),
482
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
483
+ ]))
484
+ story.append(sig_row)
485
+ story.append(Spacer(1, 4))
486
+ story.append(HRFlowable(width='100%', thickness=0.5, color=MID_GRAY))
487
+ story.append(Spacer(1, 3))
488
+
489
+ # ═══════════════════════════════════════════════════════════════════════════
490
+ # 5. FOOTER
491
+ # ═══════════════════════════════════════════════════════════════════════════
492
+ footer = Table(
493
+ [[
494
+ Paragraph(
495
+ '<font size=7><b>DermaDetect AI</b></font><br/>'
496
+ '<font size=6 color="#64748B">Generated by DermaDetect β€” AI Skin Assessment Tool</font>',
497
+ S['left']
498
+ ),
499
+ Paragraph(
500
+ f'<font size=7><b>TIMESTAMP &amp; REFERRAL REF#</b></font><br/>'
501
+ f'<font size=6 color="#64748B">Ref: {case_id} | Generated: {p_date} at 03:40 AM</font>',
502
+ S['right']
503
+ ),
504
+ ]],
505
+ colWidths=[AW * 0.5, AW * 0.5]
506
+ )
507
+ footer.setStyle(TableStyle([
508
+ ('ALIGN', (1, 0), (1, 0), 'RIGHT'),
509
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
510
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
511
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
512
+ ]))
513
+ story.append(footer)
514
+ story.append(Spacer(1, 2))
515
  story.append(Paragraph(
516
+ 'This referral note was generated with AI assistance. It is intended to support, not replace, clinical judgment.',
517
+ S['disc_center']
 
 
518
  ))
519
 
520
  doc.build(story)
521
  return base64.b64encode(buf.getvalue()).decode('utf-8')
522
 
523
 
524
+ # ── Styles ────────────────────────────────────────────────────────────────────
525
+ def _styles() -> dict:
526
  return {
527
+ 'left': ParagraphStyle('left',
528
+ fontSize=8, fontName='Helvetica', textColor=DARK,
529
+ leading=11, alignment=TA_LEFT),
530
+ 'left_sm': ParagraphStyle('left_sm',
531
+ fontSize=7, fontName='Helvetica', textColor=DARK,
532
+ leading=10, alignment=TA_LEFT),
533
+ 'left_white': ParagraphStyle('left_white',
534
+ fontSize=9, fontName='Helvetica', textColor=WHITE,
535
+ leading=13, alignment=TA_LEFT),
536
+ 'center': ParagraphStyle('center',
537
+ fontSize=9, fontName='Helvetica', textColor=DARK,
538
+ leading=13, alignment=TA_CENTER),
539
+ 'center_white': ParagraphStyle('center_white',
540
+ fontSize=9, fontName='Helvetica', textColor=WHITE,
541
+ leading=13, alignment=TA_CENTER),
542
+ 'right': ParagraphStyle('right',
543
+ fontSize=8, fontName='Helvetica', textColor=DARK,
544
+ leading=11, alignment=TA_RIGHT),
545
+ 'right_white': ParagraphStyle('right_white',
546
+ fontSize=9, fontName='Helvetica', textColor=WHITE,
547
+ leading=13, alignment=TA_RIGHT),
548
+ 'sec_head': ParagraphStyle('sec_head',
549
+ fontSize=7.5, fontName='Helvetica-Bold', textColor=TEAL,
550
+ leading=10, spaceBefore=2, spaceAfter=3),
551
+ 'sub_head': ParagraphStyle('sub_head',
552
+ fontSize=7.5, fontName='Helvetica-Bold', textColor=GRAY,
553
+ leading=10, spaceAfter=2),
554
+ 'info_lbl': ParagraphStyle('info_lbl',
555
+ fontSize=7.5, fontName='Helvetica-Bold', textColor=GRAY,
556
+ leading=10),
557
+ 'info_val': ParagraphStyle('info_val',
558
+ fontSize=7.5, fontName='Helvetica-Bold', textColor=DARK,
559
+ leading=10),
560
+ 'finding': ParagraphStyle('finding',
561
+ fontSize=16, fontName='Helvetica-Bold', textColor=DARK,
562
+ leading=20),
563
+ 'bar_lbl': ParagraphStyle('bar_lbl',
564
+ fontSize=8, fontName='Helvetica', textColor=GRAY,
565
+ leading=10, spaceAfter=2),
566
+ 'findings': ParagraphStyle('findings',
567
+ fontSize=8, fontName='Helvetica', textColor=DARK,
568
+ leading=11),
569
+ 'bullet': ParagraphStyle('bullet',
570
+ fontSize=8, fontName='Helvetica', textColor=DARK,
571
+ leading=11, leftIndent=8),
572
+ 'notes': ParagraphStyle('notes',
573
+ fontSize=8, fontName='Helvetica-Oblique', textColor=GRAY,
574
+ leading=11),
575
+ 'img_lbl': ParagraphStyle('img_lbl',
576
+ fontSize=7, fontName='Helvetica-Bold', textColor=GRAY,
577
+ leading=9, alignment=TA_CENTER),
578
+ 'box_lbl': ParagraphStyle('box_lbl',
579
+ fontSize=7, fontName='Helvetica-Bold', textColor=TEAL,
580
+ leading=10),
581
+ 'box_val': ParagraphStyle('box_val',
582
+ fontSize=8, fontName='Helvetica', textColor=DARK,
583
+ leading=11),
584
+ 'sig_name': ParagraphStyle('sig_name',
585
+ fontSize=13, fontName='Helvetica-Bold', textColor=DARK,
586
+ leading=16),
587
+ 'disc_sm': ParagraphStyle('disc_sm',
588
+ fontSize=7, fontName='Helvetica-Oblique', textColor=GRAY,
589
+ leading=9),
590
+ 'disc_center': ParagraphStyle('disc_center',
591
+ fontSize=6.5, fontName='Helvetica-Oblique', textColor=GRAY,
592
+ leading=9, alignment=TA_CENTER),
593
  }