import io import base64 import re from datetime import datetime import qrcode from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import cm from reportlab.lib import colors from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image as RLImage, HRFlowable ) from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT PAGE_W, PAGE_H = A4 MARGIN = 1.0 * cm # ── Brand palette ───────────────────────────────────────────────────────────── NAVY = colors.HexColor('#0A3369') # DermaDetect navy #0a3369 TEAL = colors.HexColor('#0D9488') # Teal headers DARK = colors.HexColor('#0F172A') # Slate-900 GRAY = colors.HexColor('#64748B') # Slate-500 MID_GRAY = colors.HexColor('#CBD5E1') # Slate-300 LIGHT_BLUE = colors.HexColor('#EBF5FB') LIGHT_TEAL = colors.HexColor('#F0FDFA') # Light teal box back BORDER_TEAL = colors.HexColor('#CCFBF1') # Teal-100 border WHITE = colors.white C_HIGH = colors.HexColor('#D85A30') C_MOD = colors.HexColor('#EF9F27') C_LOW = colors.HexColor('#082F49') C_OK = colors.HexColor('#10B981') # Green Analysis OK badge def _uc(urgency: str) -> colors.Color: u = urgency.lower() if urgency else 'low' if 'high' in u: return C_HIGH if 'mod' in u: return C_MOD return C_LOW def _md(text: str) -> str: """Minimal markdown → ReportLab HTML.""" if not text: return '' text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) return text.replace('\n', '
') def _img(b64: str, w: float, h: float): if not b64: return None if b64.startswith('data:'): b64 = b64.split(',')[1] try: return RLImage(io.BytesIO(base64.b64decode(b64)), width=w, height=h) except Exception: return None def build_pdf( case_id: str, patient: dict, clinical: dict, images: dict ) -> str: buf = io.BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, leftMargin=MARGIN, rightMargin=MARGIN, topMargin=0.8 * cm, bottomMargin=0.8 * cm, ) S = _styles() story = [] # ── Unpack data ─────────────────────────────────────────────────────────── p_name = patient.get('name') or 'Unknown Patient' p_contact = patient.get('contactNumber') or patient.get('contact') or '—' p_age = patient.get('age') or '—' p_age_str = f"{p_age} years" if str(p_age).isdigit() else str(p_age) p_sex = patient.get('sex') or '—' p_notes = patient.get('symptoms') or patient.get('notes') or '—' p_date = datetime.utcnow().strftime('%d %b %Y') hw_name = patient.get('healthWorkerName') or patient.get('clinicianName') or 'Akosua Darko' hw_role = patient.get('role') or patient.get('clinicianRole') or 'Dermatology Specialist' hw_facility = patient.get('facilityName') or patient.get('clinicianFacility') or 'Atonsu community' hw_district = patient.get('district') or 'Kumasi' hw_region = patient.get('region') or 'Greater Accra Region' hw_contact = patient.get('contact') or '0243000000' primary_finding = clinical.get('primaryFinding') or 'Vascular Tumors' confidence = clinical.get('confidence', 100) try: c_val = float(confidence) if c_val <= 1.0: c_val = c_val * 100 except Exception: c_val = 100.0 urgency = clinical.get('urgency', 'Moderate') urgency_text = clinical.get('referralNote') or 'Refer to clinic within 3 days for assessment and treatment.' accent = _uc(urgency) regimen = clinical.get('therapyRegimen') or {} med_name = regimen.get('medication') or 'None Prescribed' dosage_str = regimen.get('instructions') or regimen.get('dosage') or 'No directions specified.' t_notes = clinical.get('treatmentNotes') or [] AW = PAGE_W - 2 * MARGIN # available width = 19.0 cm LW = 7.2 * cm # left column GW = 0.4 * cm # gap RW = AW - LW - GW # right column = 11.4 cm # ═══════════════════════════════════════════════════════════════════════════ # 1. HEADER BAR # ═══════════════════════════════════════════════════════════════════════════ hdr = Table( [[ Paragraph( 'DermaDetect
' 'AI-Powered Skin Assessment', S['left_white'] ), Paragraph( 'CLINICAL REFERRAL NOTE
' f'REF: {case_id}', S['right_white'] ), ]], colWidths=[AW * 0.5, AW * 0.5] ) hdr.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), NAVY), ('ALIGN', (1, 0), (1, 0), 'RIGHT'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('TOPPADDING', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 10), ('LEFTPADDING', (0, 0), (-1, -1), 14), ('RIGHTPADDING', (0, 0), (-1, -1), 14), ])) story.append(hdr) story.append(Spacer(1, 4)) # ═══════════════════════════════════════════════════════════════════════════ # 2. URGENCY BANNER # ═══════════════════════════════════════════════════════════════════════════ banner = Table( [[Paragraph( f'● {urgency.upper()} — {urgency_text}', S['center_white'] )]], colWidths=[AW] ) banner.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), accent), ('TOPPADDING', (0, 0), (-1, -1), 7), ('BOTTOMPADDING', (0, 0), (-1, -1), 7), ('LEFTPADDING', (0, 0), (-1, -1), 10), ('RIGHTPADDING', (0, 0), (-1, -1), 10), ])) story.append(banner) story.append(Spacer(1, 6)) # ═══════════════════════════════════════════════════════════════════════════ # 3. TWO-COLUMN BODY # ═══════════════════════════════════════════════════════════════════════════ # ── LEFT COLUMN ────────────────────────────────────────────────────────── def info_tbl(rows: list) -> Table: t = Table(rows, colWidths=[2.5 * cm, LW - 2.5 * cm]) t.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('TOPPADDING', (0, 0), (-1, -1), 1.5), ('BOTTOMPADDING', (0, 0), (-1, -1), 1.5), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ])) return t def lbl(text): return Paragraph(text, S['info_lbl']) def val(text): return Paragraph(str(text), S['info_val']) def sec(text): return Paragraph(text, S['sec_head']) L = [] # Patient Information L += [ [sec('PATIENT INFORMATION')], [info_tbl([ [lbl('Full Name'), val(p_name)], [lbl('Contact Number'), val(p_contact)], [lbl('Age'), val(p_age_str)], [lbl('Sex'), val(p_sex)], [lbl('Date of Visit'), val(p_date)], [lbl('Patient ID'), val(case_id)], ])], [Spacer(1, 4)], ] # Referring Health Worker L += [ [sec('REFERRING HEALTH WORKER')], [info_tbl([ [lbl('Name'), val(hw_name)], [lbl('Role'), val(hw_role)], [lbl('Facility Name'), val(hw_facility)], [lbl('District'), val(hw_district)], [lbl('Region'), val(hw_region)], [lbl('Contact'), val(hw_contact)], ])], [Spacer(1, 4)], ] # Refer To L += [ [sec('REFER TO')], [info_tbl([ [lbl('Facility Type'), val('District Hospital / Dermatology Clinic')], [lbl('Department'), val('Dermatology / General OPD')], [lbl('Urgency'), Paragraph(f'Within { "3 days" if urgency.lower() == "moderate" else "immediate" }', ParagraphStyle('uv', parent=S['info_val'], textColor=accent))], ])], [Spacer(1, 4)], ] # Health Worker's Notes L += [ [sec("HEALTH WORKER'S NOTES")], [Table( [[Paragraph(f'"{p_notes}"', S['notes'])]], colWidths=[LW], style=[ ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#F8FAFC')), ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#E2E8F0')), ('TOPPADDING', (0, 0), (-1, -1), 6), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('LEFTPADDING', (0, 0), (-1, -1), 8), ('RIGHTPADDING', (0, 0), (-1, -1), 8), ] )], [Spacer(1, 4)], ] # Recommended Medications med_inner = Table( [ [Paragraph('PRESCRIBED MEDICATION', S['box_lbl'])], [Paragraph(med_name, S['box_val'])], [Spacer(1, 3)], [Paragraph('DOSAGE REGIMEN / DIRECTIONS', S['box_lbl'])], [Paragraph(dosage_str, S['box_val'])], ], colWidths=[LW - 0.6 * cm] ) med_inner.setStyle(TableStyle([ ('TOPPADDING', (0, 0), (-1, -1), 1), ('BOTTOMPADDING', (0, 0), (-1, -1), 1), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ])) med_box = Table([[med_inner]], colWidths=[LW]) med_box.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), LIGHT_TEAL), ('BOX', (0, 0), (-1, -1), 0.5, BORDER_TEAL), ('LEFTPADDING', (0, 0), (-1, -1), 8), ('RIGHTPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 6), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ])) L += [ [sec('RECOMMENDED MEDICATIONS')], [med_box], ] left_col = Table(L, colWidths=[LW]) left_col.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 0), ('BOTTOMPADDING', (0, 0), (-1, -1), 0), ])) # ── RIGHT COLUMN ────────────────────────────────────────────────────────── R = [] BW = RW - 0.3 * cm # bar width # AI Assessment header + ANALYSIS OK badge ok = Table( [[Paragraph('ANALYSIS OK', S['center_white'])]], colWidths=[2.3 * cm] ) ok.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), C_OK), ('TOPPADDING', (0, 0), (-1, -1), 3), ('BOTTOMPADDING', (0, 0), (-1, -1), 3), ('LEFTPADDING', (0, 0), (-1, -1), 4), ('RIGHTPADDING', (0, 0), (-1, -1), 4), ])) ai_hdr = Table([[sec('AI ASSESSMENT'), ok]], colWidths=[RW - 2.6 * cm, 2.6 * cm]) ai_hdr.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('ALIGN', (1, 0), (1, 0), 'RIGHT'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 0), ('BOTTOMPADDING', (0, 0), (-1, -1), 0), ])) R += [[ai_hdr], [Spacer(1, 4)]] # Finding Title R += [[Paragraph(primary_finding, S['finding'])], [Spacer(1, 4)]] # Confidence bar R.append([Paragraph( f'Detection confidence' f'                ' f'                ' f'                ' f'{c_val:.0f}%', S['bar_lbl'] )]) conf_ratio = c_val / 100.0 fw = BW * conf_ratio ew = BW * (1.0 - conf_ratio) if ew > 0.01: bar = Table([[None, None]], colWidths=[fw, ew]) bar.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (0, 0), NAVY), ('BACKGROUND', (1, 0), (1, 0), MID_GRAY), ('ROWHEIGHT', (0, 0), (-1, -1), 7), ])) else: bar = Table([[None]], colWidths=[BW]) bar.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (0, 0), NAVY), ('ROWHEIGHT', (0, 0), (-1, -1), 7), ])) R += [[bar], [Spacer(1, 4)]] # Urgency pill pill = Table( [[Paragraph( f'{urgency.upper()} URGENCY', S['center_white'] )]], colWidths=[3.2 * cm] ) pill.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), accent), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('LEFTPADDING', (0, 0), (-1, -1), 6), ('RIGHTPADDING', (0, 0), (-1, -1), 6), ])) R += [[pill], [Spacer(1, 4)]] # Assessment narrative text assessment_narrative = ( f"A potential clinical skin indication detected by the assistive triage scanner. " f"Standard clinical diagnostic procedures are recommended before commencing definitive therapy." ) R += [[Paragraph(assessment_narrative, S['findings'])], [Spacer(1, 4)]] # Suggested Treatment if t_notes: R.append([Paragraph('SUGGESTED TREATMENT', S['sub_head'])]) for note in t_notes[:3]: R.append([Paragraph(f'• {note}', S['bullet'])]) R.append([Spacer(1, 4)]) # Disclaimer disc = "This is an AI-generated suggestion. Final treatment decisions rest with the clinician." R += [[Paragraph(f'{disc}', S['disc_sm'])], [Spacer(1, 4)]] # Visual Analysis (2 images side-by-side) R.append([Paragraph('PHOTO TAKEN DURING ASSESSMENT', S['sub_head'])]) R.append([Spacer(1, 4)]) IW = (RW - 0.4 * cm) / 2 IH = 4.2 * cm cells = [] labels = [] im1 = _img(images.get('original_b64'), IW, IH) cells.append(im1 if im1 else Paragraph('—', S['center'])) labels.append(Paragraph('Clinical Specimen', S['img_lbl'])) im2 = _img(images.get('heatmap_b64'), IW, IH) cells.append(im2 if im2 else Paragraph('—', S['center'])) labels.append(Paragraph('AI Saliency Map', S['img_lbl'])) img_table = Table([cells, labels], colWidths=[IW + 0.2 * cm, IW + 0.2 * cm]) img_table.setStyle(TableStyle([ ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('TOPPADDING', (0, 0), (-1, -1), 1), ('BOTTOMPADDING', (0, 0), (-1, -1), 1), ])) R.append([img_table]) right_col = Table(R, colWidths=[RW]) right_col.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 0), ('BOTTOMPADDING', (0, 0), (-1, -1), 0), ])) # Assemble body = Table( [[left_col, Spacer(GW, 1), right_col]], colWidths=[LW, GW, RW] ) body.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 0), ('BOTTOMPADDING', (0, 0), (-1, -1), 0), ])) story.append(body) story.append(Spacer(1, 6)) story.append(HRFlowable(width='100%', thickness=0.5, color=MID_GRAY)) story.append(Spacer(1, 4)) # ═══════════════════════════════════════════════════════════════════════════ # 4. SIGNATURE ROW # ═══════════════════════════════════════════════════════════════════════════ SLW = AW * 0.55 SRW = AW * 0.45 sig_left = Table( [ [Paragraph('HEALTH WORKER SIGNATURE', S['left'])], [Spacer(1, 4)], [Paragraph(f'{hw_name}', S['sig_name'])], [Paragraph(f'{hw_role} - {hw_facility}', S['left_sm'])], [Paragraph(f'Date: {p_date}', S['left_sm'])], ], colWidths=[SLW] ) stamp = Table( [[Paragraph( 'PLACE CLINICAL STAMP HERE', S['center'] )]], colWidths=[SRW - 0.5 * cm], rowHeights=[2.2 * cm] ) stamp.setStyle(TableStyle([ ('BOX', (0, 0), (-1, -1), 0.5, MID_GRAY), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ])) sig_right_wrap = Table([ [stamp], [Spacer(1, 2)], [Paragraph('* To be completed at receiving facility', S['right'])] ], colWidths=[SRW]) sig_right_wrap.setStyle(TableStyle([ ('ALIGN', (0, 0), (-1, -1), 'RIGHT'), ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ])) sig_row = Table([[sig_left, sig_right_wrap]], colWidths=[SLW, SRW]) sig_row.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 0), ('BOTTOMPADDING', (0, 0), (-1, -1), 0), ])) story.append(sig_row) story.append(Spacer(1, 4)) story.append(HRFlowable(width='100%', thickness=0.5, color=MID_GRAY)) story.append(Spacer(1, 3)) # ═══════════════════════════════════════════════════════════════════════════ # 5. FOOTER # ═══════════════════════════════════════════════════════════════════════════ footer = Table( [[ Paragraph( 'DermaDetect AI
' 'Generated by DermaDetect — AI Skin Assessment Tool', S['left'] ), Paragraph( f'TIMESTAMP & REFERRAL REF#
' f'Ref: {case_id} | Generated: {p_date} at 03:40 AM', S['right'] ), ]], colWidths=[AW * 0.5, AW * 0.5] ) footer.setStyle(TableStyle([ ('ALIGN', (1, 0), (1, 0), 'RIGHT'), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ])) story.append(footer) story.append(Spacer(1, 2)) story.append(Paragraph( 'This referral note was generated with AI assistance. It is intended to support, not replace, clinical judgment.', S['disc_center'] )) doc.build(story) return base64.b64encode(buf.getvalue()).decode('utf-8') # ── Styles ──────────────────────────────────────────────────────────────────── def _styles() -> dict: return { 'left': ParagraphStyle('left', fontSize=8, fontName='Helvetica', textColor=DARK, leading=11, alignment=TA_LEFT), 'left_sm': ParagraphStyle('left_sm', fontSize=7, fontName='Helvetica', textColor=DARK, leading=10, alignment=TA_LEFT), 'left_white': ParagraphStyle('left_white', fontSize=9, fontName='Helvetica', textColor=WHITE, leading=13, alignment=TA_LEFT), 'center': ParagraphStyle('center', fontSize=9, fontName='Helvetica', textColor=DARK, leading=13, alignment=TA_CENTER), 'center_white': ParagraphStyle('center_white', fontSize=9, fontName='Helvetica', textColor=WHITE, leading=13, alignment=TA_CENTER), 'right': ParagraphStyle('right', fontSize=8, fontName='Helvetica', textColor=DARK, leading=11, alignment=TA_RIGHT), 'right_white': ParagraphStyle('right_white', fontSize=9, fontName='Helvetica', textColor=WHITE, leading=13, alignment=TA_RIGHT), 'sec_head': ParagraphStyle('sec_head', fontSize=7.5, fontName='Helvetica-Bold', textColor=TEAL, leading=10, spaceBefore=2, spaceAfter=3), 'sub_head': ParagraphStyle('sub_head', fontSize=7.5, fontName='Helvetica-Bold', textColor=GRAY, leading=10, spaceAfter=2), 'info_lbl': ParagraphStyle('info_lbl', fontSize=7.5, fontName='Helvetica-Bold', textColor=GRAY, leading=10), 'info_val': ParagraphStyle('info_val', fontSize=7.5, fontName='Helvetica-Bold', textColor=DARK, leading=10), 'finding': ParagraphStyle('finding', fontSize=16, fontName='Helvetica-Bold', textColor=DARK, leading=20), 'bar_lbl': ParagraphStyle('bar_lbl', fontSize=8, fontName='Helvetica', textColor=GRAY, leading=10, spaceAfter=2), 'findings': ParagraphStyle('findings', fontSize=8, fontName='Helvetica', textColor=DARK, leading=11), 'bullet': ParagraphStyle('bullet', fontSize=8, fontName='Helvetica', textColor=DARK, leading=11, leftIndent=8), 'notes': ParagraphStyle('notes', fontSize=8, fontName='Helvetica-Oblique', textColor=GRAY, leading=11), 'img_lbl': ParagraphStyle('img_lbl', fontSize=7, fontName='Helvetica-Bold', textColor=GRAY, leading=9, alignment=TA_CENTER), 'box_lbl': ParagraphStyle('box_lbl', fontSize=7, fontName='Helvetica-Bold', textColor=TEAL, leading=10), 'box_val': ParagraphStyle('box_val', fontSize=8, fontName='Helvetica', textColor=DARK, leading=11), 'sig_name': ParagraphStyle('sig_name', fontSize=13, fontName='Helvetica-Bold', textColor=DARK, leading=16), 'disc_sm': ParagraphStyle('disc_sm', fontSize=7, fontName='Helvetica-Oblique', textColor=GRAY, leading=9), 'disc_center': ParagraphStyle('disc_center', fontSize=6.5, fontName='Helvetica-Oblique', textColor=GRAY, leading=9, alignment=TA_CENTER), }