Create a pdf of PFT reports including obstructive restrictive and mixed condition
mkdir -p /tmp/workspace/pft-reports && python3 << 'PYEOF' from reportlab.lib.pagesizes import A4 from reportlab.lib import colors from reportlab.lib.units import mm, inch from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, PageBreak, KeepTogether) from reportlab.platypus.flowables import BalancedColumns from reportlab.graphics.shapes import Drawing, Line, Rect, Polygon, String, Circle from reportlab.graphics.charts.lineplots import LinePlot from reportlab.graphics import renderPDF import math PAGE_W, PAGE_H = A4 MARGIN = 18*mm # ── Color palette ────────────────────────────────────────────── C_HEADER_BG = colors.HexColor("#1A3C5E") C_HEADER_TEXT = colors.white C_BAND_BLUE = colors.HexColor("#E8F0F8") C_BAND_LIGHT = colors.HexColor("#F4F8FC") C_ACCENT = colors.HexColor("#2E6DA4") C_RED = colors.HexColor("#C0392B") C_GREEN = colors.HexColor("#1E8449") C_ORANGE = colors.HexColor("#D35400") C_YELLOW_BG = colors.HexColor("#FFFDE7") C_GREY_LINE = colors.HexColor("#BDC3C7") C_DARK = colors.HexColor("#1C2833") C_MILD = colors.HexColor("#F0F4FA") # ── Styles ───────────────────────────────────────────────────── styles = getSampleStyleSheet() def S(name, **kw): return ParagraphStyle(name, parent=styles['Normal'], **kw) sTitle = S('sTitle', fontSize=18, textColor=C_HEADER_TEXT, alignment=TA_CENTER, fontName='Helvetica-Bold', spaceAfter=2) sSubtitle = S('sSubtitle', fontSize=10, textColor=C_HEADER_TEXT, alignment=TA_CENTER, fontName='Helvetica') sSection = S('sSection', fontSize=11, textColor=C_HEADER_TEXT, fontName='Helvetica-Bold', alignment=TA_LEFT, spaceAfter=3, spaceBefore=2) sBody = S('sBody', fontSize=8.5, leading=13, textColor=C_DARK, fontName='Helvetica') sBodyBold = S('sBodyBold', fontSize=8.5, leading=13, textColor=C_DARK, fontName='Helvetica-Bold') sSmall = S('sSmall', fontSize=7.5, leading=11, textColor=colors.HexColor("#555")) sNormal = S('sNormal', fontSize=9, leading=13) sInterpret = S('sInterpret', fontSize=9, leading=14, textColor=C_DARK, fontName='Helvetica', borderPadding=6) sPageNo = S('sPageNo', fontSize=7, textColor=colors.grey, alignment=TA_RIGHT) def colored_header(text, bg=C_HEADER_BG, fg=C_HEADER_TEXT, size=11): return Table([[Paragraph(text, ParagraphStyle('h', fontName='Helvetica-Bold', fontSize=size, textColor=fg, alignment=TA_LEFT))]], colWidths=[PAGE_W - 2*MARGIN], style=[('BACKGROUND', (0,0), (-1,-1), bg), ('ROWBACKGROUNDS', (0,0), (-1,-1), [bg]), ('TOPPADDING',(0,0),(-1,-1),5), ('BOTTOMPADDING',(0,0),(-1,-1),5), ('LEFTPADDING',(0,0),(-1,-1),8)]) def hr(color=C_GREY_LINE, width=0.5): return HRFlowable(width="100%", thickness=width, color=color, spaceAfter=4, spaceBefore=4) def field_row(pairs): """pairs: list of (label, value) – 2 or 4 per row""" data = [] row = [] for i, (lbl, val) in enumerate(pairs): row.append(Paragraph(f"<b>{lbl}:</b>", sSmall)) row.append(Paragraph(str(val), sSmall)) if len(row) == 4 or i == len(pairs)-1: while len(row) < 4: row.extend([Paragraph("", sSmall), Paragraph("", sSmall)]) data.append(row) row = [] cw = [(PAGE_W-2*MARGIN)/4]*4 t = Table(data, colWidths=cw) t.setStyle(TableStyle([ ('FONTNAME',(0,0),(-1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),8.5), ('VALIGN',(0,0),(-1,-1),'MIDDLE'), ('TOPPADDING',(0,0),(-1,-1),3), ('BOTTOMPADDING',(0,0),(-1,-1),3), ('ROWBACKGROUNDS',(0,0),(-1,-1),[C_BAND_LIGHT, C_MILD]), ])) return t def result_table(rows, title, abnormal_rows=None): """ rows: list of [Param, Pred, Pre-BD, %Pred, Post-BD, %Pred, flag] abnormal_rows: set of row indices to highlight """ header = [ Paragraph('<b>Parameter</b>', S('th', fontName='Helvetica-Bold', fontSize=8, textColor=C_HEADER_TEXT, alignment=TA_LEFT)), Paragraph('<b>Pred</b>', S('th2', fontName='Helvetica-Bold', fontSize=8, textColor=C_HEADER_TEXT, alignment=TA_CENTER)), Paragraph('<b>Pre-BD</b>', S('th3', fontName='Helvetica-Bold', fontSize=8, textColor=C_HEADER_TEXT, alignment=TA_CENTER)), Paragraph('<b>% Pred</b>', S('th4', fontName='Helvetica-Bold', fontSize=8, textColor=C_HEADER_TEXT, alignment=TA_CENTER)), Paragraph('<b>Post-BD</b>', S('th5', fontName='Helvetica-Bold', fontSize=8, textColor=C_HEADER_TEXT, alignment=TA_CENTER)), Paragraph('<b>% Pred</b>', S('th6', fontName='Helvetica-Bold', fontSize=8, textColor=C_HEADER_TEXT, alignment=TA_CENTER)), Paragraph('<b>Flag</b>', S('th7', fontName='Helvetica-Bold', fontSize=8, textColor=C_HEADER_TEXT, alignment=TA_CENTER)), ] cw = [60*mm, 22*mm, 22*mm, 18*mm, 22*mm, 18*mm, 16*mm] data = [header] for i, r in enumerate(rows): styled = [] for j, cell in enumerate(r): if j == 0: styled.append(Paragraph(str(cell), S('td0', fontSize=8.5, fontName='Helvetica'))) elif j == 6: col = C_RED if str(cell) in ('↓','↑','↓↓','Abnormal') else C_GREEN styled.append(Paragraph(str(cell), S('tdf', fontSize=9, fontName='Helvetica-Bold', textColor=col, alignment=TA_CENTER))) else: styled.append(Paragraph(str(cell), S('tdn', fontSize=8.5, alignment=TA_CENTER, fontName='Helvetica'))) data.append(styled) t = Table(data, colWidths=cw, repeatRows=1) ts = TableStyle([ ('BACKGROUND',(0,0),(-1,0), C_ACCENT), ('ROWBACKGROUNDS',(0,1),(-1,-1),[colors.white, C_BAND_BLUE]), ('FONTNAME',(0,0),(-1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),8.5), ('VALIGN',(0,0),(-1,-1),'MIDDLE'), ('TOPPADDING',(0,0),(-1,-1),4), ('BOTTOMPADDING',(0,0),(-1,-1),4), ('LEFTPADDING',(0,0),(-1,-1),6), ('GRID',(0,0),(-1,-1),0.4,C_GREY_LINE), ('LINEBELOW',(0,0),(-1,0),1.5,C_HEADER_BG), ]) if abnormal_rows: for ri in abnormal_rows: ts.add('TEXTCOLOR', (1, ri+1), (5, ri+1), C_RED) ts.add('FONTNAME', (1, ri+1), (5, ri+1), 'Helvetica-Bold') t.setStyle(ts) return t # ═══════════════════════════════════════════════════════════════ # SPIROMETRY CURVE DRAWING (Flow-Volume Loop) # ═══════════════════════════════════════════════════════════════ def draw_flow_volume(pattern, w=170, h=110): """Draw a schematic flow-volume loop for the given pattern.""" d = Drawing(w, h) # background d.add(Rect(0, 0, w, h, fillColor=colors.HexColor("#F8FBFF"), strokeColor=C_GREY_LINE, strokeWidth=0.5)) ox, oy = 20, h/2 # origin of axes aw = w - 30 # axis width ah = h - 25 # axis half-height # axes d.add(Line(ox, oy - ah*0.6, ox, oy + ah*0.7, strokeColor=colors.grey, strokeWidth=0.8)) d.add(Line(ox, oy, ox + aw, oy, strokeColor=colors.grey, strokeWidth=0.8)) # axis labels d.add(String(ox + aw/2, 3, "Volume (L)", fontSize=6, fillColor=colors.grey, textAnchor='middle')) d.add(String(3, oy + ah*0.35, "Flow", fontSize=6, fillColor=colors.grey, textAnchor='middle')) d.add(String(3, oy - ah*0.35, "(L/s)", fontSize=6, fillColor=colors.grey, textAnchor='middle')) d.add(String(ox + aw - 2, oy - 8, "TLC", fontSize=6, fillColor=colors.grey)) d.add(String(ox + 2, oy - 8, "RV", fontSize=6, fillColor=colors.grey)) # tick marks on volume axis for i in range(5): xp = ox + i * aw/4 d.add(Line(xp, oy-3, xp, oy+3, strokeColor=colors.grey, strokeWidth=0.5)) if pattern == 'normal': pts_exp = [(0,0),(0.08,0.85),(0.2,1.0),(0.5,0.85),(0.75,0.65),(1.0,0.3),(1.0,0)] pts_ins = [(1.0,0),(0.9,-0.6),(0.5,-0.85),(0.1,-0.5),(0,0)] clr = C_GREEN label = "Normal" elif pattern == 'obstructive': pts_exp = [(0,0),(0.06,0.55),(0.2,0.65),(0.5,0.45),(0.75,0.22),(1.0,0.05),(1.0,0)] pts_ins = [(1.0,0),(0.9,-0.62),(0.5,-0.87),(0.1,-0.52),(0,0)] clr = C_RED label = "Obstructive" elif pattern == 'restrictive': pts_exp = [(0.3,0),(0.38,0.9),(0.5,1.0),(0.65,0.8),(0.8,0.5),(1.0,0.15),(1.0,0)] pts_ins = [(1.0,0),(0.88,-0.8),(0.65,-1.0),(0.45,-0.8),(0.3,0)] clr = C_ORANGE label = "Restrictive" else: # mixed pts_exp = [(0.2,0),(0.27,0.55),(0.4,0.6),(0.6,0.38),(0.8,0.16),(1.0,0.04),(1.0,0)] pts_ins = [(1.0,0),(0.88,-0.62),(0.6,-0.8),(0.3,-0.55),(0.2,0)] clr = colors.HexColor("#6A0DAD") label = "Mixed" def pts2coords(pts): return [(ox + p[0]*aw, oy + p[1]*ah*0.6) for p in pts] # Draw normal reference (dotted grey) if pattern != 'normal': ref_exp = [(0,0),(0.08,0.85),(0.2,1.0),(0.5,0.85),(0.75,0.65),(1.0,0.3),(1.0,0)] ref_ins = [(1.0,0),(0.9,-0.6),(0.5,-0.85),(0.1,-0.5),(0,0)] all_pts = pts2coords(ref_exp) + pts2coords(ref_ins) for i in range(len(all_pts)-1): if i > 0 and i % 2 == 0: d.add(Line(all_pts[i][0], all_pts[i][1], all_pts[i+1][0], all_pts[i+1][1], strokeColor=colors.HexColor("#BBBBBB"), strokeWidth=0.7, strokeDashArray=[2,2])) # Draw actual curve exp_coords = pts2coords(pts_exp) ins_coords = pts2coords(pts_ins) all_curve = exp_coords + ins_coords for i in range(len(all_curve)-1): d.add(Line(all_curve[i][0], all_curve[i][1], all_curve[i+1][0], all_curve[i+1][1], strokeColor=clr, strokeWidth=1.8)) # Pattern label d.add(String(ox + aw/2, h - 10, label + " Pattern", fontSize=7.5, fillColor=clr, textAnchor='middle', fontName='Helvetica-Bold')) return d # ═══════════════════════════════════════════════════════════════ # VOLUME-TIME CURVE (spirogram) # ═══════════════════════════════════════════════════════════════ def draw_volume_time(pattern, w=160, h=110): d = Drawing(w, h) d.add(Rect(0, 0, w, h, fillColor=colors.HexColor("#F8FBFF"), strokeColor=C_GREY_LINE, strokeWidth=0.5)) ox, oy = 20, 12 aw = w - 28 ah = h - 22 d.add(Line(ox, oy, ox, oy+ah, strokeColor=colors.grey, strokeWidth=0.8)) d.add(Line(ox, oy+ah, ox+aw, oy+ah, strokeColor=colors.grey, strokeWidth=0.8)) d.add(String(ox + aw/2, 2, "Time (s)", fontSize=6, fillColor=colors.grey, textAnchor='middle')) d.add(String(5, oy + ah/2, "Vol", fontSize=6, fillColor=colors.grey, textAnchor='middle')) d.add(String(5, oy + ah/2 - 7, "(L)", fontSize=6, fillColor=colors.grey, textAnchor='middle')) # time markers 0,1,2,3,6 s for t, lbl in [(0,'0'),(1,'1'),(2,'2'),(3,'3'),(6,'6')]: xp = ox + t * aw/6.5 d.add(Line(xp, oy+ah-3, xp, oy+ah+3, strokeColor=colors.grey, strokeWidth=0.5)) d.add(String(xp, oy+ah+5, lbl, fontSize=5.5, fillColor=colors.grey, textAnchor='middle')) def sigmoid_curve(t, fvc, fev1, pattern_mod=1.0): """Simple spirogram: volume rises fast then plateaus""" if pattern == 'obstructive': # Slow rise, incomplete plateau return fvc * (1 - math.exp(-0.4 * t * pattern_mod)) elif pattern == 'restrictive': # Fast rise, lower plateau return fvc * (1 - math.exp(-1.5 * t)) else: # mixed return fvc * (1 - math.exp(-0.55 * t)) if pattern == 'normal': fvc, fev1 = 4.2, 3.5 clr = C_GREEN ref_pts = [(t/10, 4.2*(1-math.exp(-1.2*t/10*6.5))) for t in range(0, 70)] elif pattern == 'obstructive': fvc, fev1 = 3.8, 1.8 clr = C_RED ref_pts = [(t/10, fvc*(1-math.exp(-0.4*(t/10)*6.5))) for t in range(0, 70)] elif pattern == 'restrictive': fvc, fev1 = 2.6, 2.2 clr = C_ORANGE ref_pts = [(t/10, fvc*(1-math.exp(-1.5*(t/10)*6.5))) for t in range(0, 70)] else: fvc, fev1 = 2.9, 1.5 clr = colors.HexColor("#6A0DAD") ref_pts = [(t/10, fvc*(1-math.exp(-0.55*(t/10)*6.5))) for t in range(0, 70)] max_v = 5.5 def tx(t): return ox + t * aw / 6.5 def vy(v): return oy + ah - (v / max_v) * ah # Grey normal reference if pattern != 'normal': norm_pts = [(t/10, 4.2*(1-math.exp(-1.2*(t/10)*6.5))) for t in range(0,70)] coords = [(tx(p[0]), vy(min(p[1], 4.2))) for p in norm_pts] for i in range(len(coords)-1): if i % 2 == 0: d.add(Line(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1], strokeColor=colors.HexColor("#CCCCCC"), strokeWidth=0.7, strokeDashArray=[2,2])) coords = [(tx(p[0]), vy(min(p[1], fvc))) for p in ref_pts] for i in range(len(coords)-1): d.add(Line(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1], strokeColor=clr, strokeWidth=2.0)) # FEV1 marker line fev1_y = vy(fev1) d.add(Line(ox, fev1_y, tx(1), fev1_y, strokeColor=clr, strokeWidth=0.7, strokeDashArray=[3,2])) d.add(Line(tx(1), vy(0.0), tx(1), fev1_y, strokeColor=clr, strokeWidth=0.7, strokeDashArray=[3,2])) d.add(String(tx(1)+2, fev1_y+2, f'FEV₁={fev1}L', fontSize=5.5, fillColor=clr, fontName='Helvetica-Bold')) label = {'normal':'Normal','obstructive':'Obstructive', 'restrictive':'Restrictive','mixed':'Mixed'}[pattern] d.add(String(ox + aw/2, h-9, f"Volume-Time Curve – {label}", fontSize=7.5, fillColor=clr, textAnchor='middle', fontName='Helvetica-Bold')) return d # ═══════════════════════════════════════════════════════════════ # SEVERITY BAR # ═══════════════════════════════════════════════════════════════ def severity_bar(fev1_pct, label): """Visual severity indicator.""" w, h = PAGE_W - 2*MARGIN, 22 d = Drawing(w, h) segs = [ (0.0, 0.35, C_RED, 'Very Severe\n<35%'), (0.35, 0.50, colors.HexColor("#E74C3C"),'Severe\n35-49%'), (0.50, 0.65, C_ORANGE, 'Moderate\n50-64%'), (0.65, 0.80, colors.HexColor("#F1C40F"),'Mild\n65-79%'), (0.80, 1.00, C_GREEN, 'Normal\n≥80%'), ] bar_w = w - 20 for (s, e, c, lbl) in segs: xstart = 10 + s * bar_w seg_w = (e - s) * bar_w d.add(Rect(xstart, 8, seg_w, 10, fillColor=c, strokeColor=colors.white, strokeWidth=1)) d.add(String(xstart + seg_w/2, 1, lbl.split('\n')[1], fontSize=5, fillColor=colors.HexColor("#333"), textAnchor='middle')) # marker marker_x = 10 + (fev1_pct/100) * bar_w d.add(Polygon([marker_x-5, 18, marker_x+5, 18, marker_x, 8], fillColor=C_HEADER_BG, strokeColor=C_HEADER_BG)) d.add(String(marker_x, 19, f'{fev1_pct}%', fontSize=6.5, fillColor=C_HEADER_BG, textAnchor='middle', fontName='Helvetica-Bold')) return d # ═══════════════════════════════════════════════════════════════ # COVER PAGE # ═══════════════════════════════════════════════════════════════ def build_cover(): elems = [] elems.append(Spacer(1, 30*mm)) # big title box cover_data = [[ Paragraph("PULMONARY FUNCTION TEST", ParagraphStyle( 'cvt', fontName='Helvetica-Bold', fontSize=26, textColor=C_HEADER_TEXT, alignment=TA_CENTER)), ],[ Paragraph("DIAGNOSTIC REPORT", ParagraphStyle( 'cvs', fontName='Helvetica-Bold', fontSize=18, textColor=colors.HexColor("#A8C8E8"), alignment=TA_CENTER)), ],[ Paragraph("Obstructive · Restrictive · Mixed Ventilatory Patterns", ParagraphStyle( 'cvsub', fontName='Helvetica', fontSize=11, textColor=colors.HexColor("#D0E4F5"), alignment=TA_CENTER)), ]] cover_tbl = Table(cover_data, colWidths=[PAGE_W - 2*MARGIN]) cover_tbl.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,-1), C_HEADER_BG), ('TOPPADDING',(0,0),(-1,-1), 14), ('BOTTOMPADDING',(0,0),(-1,-1), 14), ('ROUNDEDCORNERS', [6]), ])) elems.append(cover_tbl) elems.append(Spacer(1, 10*mm)) info_data = [ ["Laboratory:", "Department of Pulmonary Medicine"], ["Reference Standard:", "ATS/ERS 2019 Guidelines (GLI-2012 Reference Equations)"], ["Report Date:", "August 07, 2026"], ["Classification:", "Obstructive | Restrictive | Mixed"], ["Equipment:", "Jaeger MasterScope Spirometer (calibrated daily)"], ["Technician:", "Certified Pulmonary Function Technologist"], ] info_tbl = Table(info_data, colWidths=[55*mm, PAGE_W-2*MARGIN-55*mm]) info_tbl.setStyle(TableStyle([ ('FONTNAME',(0,0),(0,-1),'Helvetica-Bold'), ('FONTNAME',(1,0),(1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),9), ('ROWBACKGROUNDS',(0,0),(-1,-1),[C_BAND_LIGHT, colors.white]), ('TOPPADDING',(0,0),(-1,-1),5), ('BOTTOMPADDING',(0,0),(-1,-1),5), ('LEFTPADDING',(0,0),(-1,-1),8), ('GRID',(0,0),(-1,-1),0.3, C_GREY_LINE), ('LINEBELOW',(0,-1),(-1,-1),1, C_ACCENT), ])) elems.append(info_tbl) elems.append(Spacer(1, 15*mm)) # Three condition summary boxes box_data = [[ Table([[Paragraph("CASE 1", S('bh', fontName='Helvetica-Bold', fontSize=10, textColor=colors.white, alignment=TA_CENTER))], [Paragraph("Obstructive", S('bt', fontName='Helvetica-Bold', fontSize=13, textColor=colors.white, alignment=TA_CENTER))], [Paragraph("COPD / Asthma", S('bd', fontName='Helvetica', fontSize=8, textColor=colors.HexColor("#FFE0E0"), alignment=TA_CENTER))], [Paragraph("FEV₁/FVC < LLN\nFEV₁ ↓↓ | FVC normal/↓\nTLC normal/↑", S('bv', fontName='Helvetica', fontSize=8, textColor=colors.white, alignment=TA_CENTER, leading=12))]], style=[('BACKGROUND',(0,0),(-1,-1),C_RED), ('TOPPADDING',(0,0),(-1,-1),8), ('BOTTOMPADDING',(0,0),(-1,-1),8), ('ROUNDEDCORNERS',[5])]), Table([[Paragraph("CASE 2", S('bh2', fontName='Helvetica-Bold', fontSize=10, textColor=colors.white, alignment=TA_CENTER))], [Paragraph("Restrictive", S('bt2', fontName='Helvetica-Bold', fontSize=13, textColor=colors.white, alignment=TA_CENTER))], [Paragraph("ILD / Fibrosis", S('bd2', fontName='Helvetica', fontSize=8, textColor=colors.HexColor("#FFE8CC"), alignment=TA_CENTER))], [Paragraph("FEV₁/FVC ≥ LLN\nFVC ↓↓ | TLC ↓↓\nDLCO ↓↓", S('bv2', fontName='Helvetica', fontSize=8, textColor=colors.white, alignment=TA_CENTER, leading=12))]], style=[('BACKGROUND',(0,0),(-1,-1),C_ORANGE), ('TOPPADDING',(0,0),(-1,-1),8), ('BOTTOMPADDING',(0,0),(-1,-1),8), ('ROUNDEDCORNERS',[5])]), Table([[Paragraph("CASE 3", S('bh3', fontName='Helvetica-Bold', fontSize=10, textColor=colors.white, alignment=TA_CENTER))], [Paragraph("Mixed", S('bt3', fontName='Helvetica-Bold', fontSize=13, textColor=colors.white, alignment=TA_CENTER))], [Paragraph("COPD + Fibrosis", S('bd3', fontName='Helvetica', fontSize=8, textColor=colors.HexColor("#E8CCFF"), alignment=TA_CENTER))], [Paragraph("FEV₁/FVC < LLN\nFVC ↓↓ | TLC ↓↓\nFEV₁ ↓↓↓", S('bv3', fontName='Helvetica', fontSize=8, textColor=colors.white, alignment=TA_CENTER, leading=12))]], style=[('BACKGROUND',(0,0),(-1,-1), colors.HexColor("#6A0DAD")), ('TOPPADDING',(0,0),(-1,-1),8), ('BOTTOMPADDING',(0,0),(-1,-1),8), ('ROUNDEDCORNERS',[5])]), ]] cw3 = [(PAGE_W - 2*MARGIN - 6*mm)/3]*3 boxes = Table(box_data, colWidths=cw3, spaceBefore=5) boxes.setStyle(TableStyle([ ('LEFTPADDING',(0,0),(-1,-1),3), ('RIGHTPADDING',(0,0),(-1,-1),3), ('VALIGN',(0,0),(-1,-1),'TOP'), ])) elems.append(boxes) elems.append(Spacer(1, 15*mm)) # Interpretation guide elems.append(colored_header(" Interpretation Reference – Severity Grading (ATS/ERS 2019)", size=10)) sev_data = [ ['FEV₁ % Predicted', 'Classification', 'FEV₁ % Predicted', 'Classification'], ['≥ 80%', 'Normal / Within LLN', '50 – 64%', 'Moderate'], ['70 – 79%', 'Mild', '35 – 49%', 'Severe'], ['65 – 69%', 'Mild-Moderate', '< 35%', 'Very Severe'], ] sev_tbl = Table(sev_data, colWidths=[(PAGE_W-2*MARGIN)/4]*4) sev_tbl.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,0), C_BAND_BLUE), ('FONTNAME',(0,0),(-1,0),'Helvetica-Bold'), ('FONTNAME',(0,1),(-1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),8.5), ('ROWBACKGROUNDS',(0,1),(-1,-1),[colors.white, C_MILD]), ('GRID',(0,0),(-1,-1),0.3,C_GREY_LINE), ('TOPPADDING',(0,0),(-1,-1),4), ('BOTTOMPADDING',(0,0),(-1,-1),4), ('ALIGN',(0,0),(-1,-1),'CENTER'), ])) elems.append(sev_tbl) elems.append(PageBreak()) return elems # ═══════════════════════════════════════════════════════════════ # CASE 1: OBSTRUCTIVE (COPD - Moderate) # ═══════════════════════════════════════════════════════════════ def build_case1(): elems = [] # Header hdr = Table([[Paragraph("CASE 1: OBSTRUCTIVE VENTILATORY DEFECT", S('ch', fontName='Helvetica-Bold', fontSize=13, textColor=C_HEADER_TEXT, alignment=TA_LEFT)), Paragraph("COPD – Moderate Severity", S('cs', fontName='Helvetica', fontSize=10, textColor=colors.HexColor("#FFD0D0"), alignment=TA_RIGHT))]], colWidths=[(PAGE_W-2*MARGIN)*0.65, (PAGE_W-2*MARGIN)*0.35]) hdr.setStyle(TableStyle([('BACKGROUND',(0,0),(-1,-1), C_RED), ('TOPPADDING',(0,0),(-1,-1),8), ('BOTTOMPADDING',(0,0),(-1,-1),8), ('LEFTPADDING',(0,0),(0,-1),10), ('RIGHTPADDING',(-1,0),(-1,-1),10)])) elems.append(hdr) elems.append(Spacer(1, 4)) # Patient demographics elems.append(colored_header(" Patient Demographics & Clinical Information", size=9)) elems.append(field_row([ ("Name", "Mr. Rajesh Kumar"), ("Age / Sex", "58 yrs / Male"), ("Height", "168 cm"), ("Weight", "74 kg"), ("BMI", "26.2 kg/m²"), ("Ethnicity", "South Asian"), ("Referring Physician", "Dr. S. Mehta, MD"), ("Date of Test", "07-Aug-2026"), ("Clinical Indication", "Chronic cough, exertional dyspnoea, >40 pack-year smoking history"), ("", ""), ])) elems.append(Spacer(1, 4)) # Spirometry results elems.append(colored_header(" Spirometry Results (Pre & Post Bronchodilator)", size=9)) rows = [ ["FVC (L)", "3.92", "3.62", "92%", "3.70", "94%", "—"], ["FEV₁ (L)", "3.21", "1.85", "58%", "1.98", "62%", "↓"], ["FEV₁/FVC (%)", "81.9", "51.1", "62%", "53.5", "65%", "↓"], ["FEF 25-75% (L/s)", "3.28", "0.92", "28%", "1.10", "34%", "↓"], ["PEF (L/s)", "8.54", "5.30", "62%", "5.85", "68%", "↓"], ["FEV₆ (L)", "3.88", "3.55", "91%", "3.62", "93%", "—"], ["FEV₁/FEV₆ (%)", "82.7", "52.1", "63%", "54.7", "66%", "↓"], ["Inspiratory Capacity (L)","2.74","1.95","71%","2.10","77%", "↓"], ] elems.append(result_table(rows, "Spirometry", abnormal_rows={1,2,3,4,5,6,7})) elems.append(Spacer(1, 4)) # Lung Volumes & DLCO elems.append(colored_header(" Lung Volumes & Diffusing Capacity (DLCO)", size=9)) vol_rows = [ ["TLC (L)", "6.42", "7.15", "111%", "—", "—", "↑"], ["RV (L)", "2.50", "3.55", "142%", "—", "—", "↑"], ["FRC (L)", "3.50", "4.30", "123%", "—", "—", "↑"], ["RV/TLC (%)", "38.9", "49.7", "128%", "—", "—", "↑"], ["DLCO (mL/min/mmHg)", "28.5", "16.8", "59%", "—", "—", "↓"], ["DLCO/VA (KCO)", "4.8", "2.9", "60%", "—", "—", "↓"], ] elems.append(result_table(vol_rows, "Volumes", abnormal_rows={0,1,2,3,4,5})) elems.append(Spacer(1, 5)) # Curves side by side curves = Table([[draw_flow_volume('obstructive'), draw_volume_time('obstructive')]], colWidths=[(PAGE_W-2*MARGIN)/2]*2) curves.setStyle(TableStyle([ ('LEFTPADDING',(0,0),(-1,-1),3), ('RIGHTPADDING',(0,0),(-1,-1),3), ('TOPPADDING',(0,0),(-1,-1),2), ('BOTTOMPADDING',(0,0),(-1,-1),2), ])) elems.append(curves) elems.append(Spacer(1, 4)) # Severity bar elems.append(Paragraph("<b>FEV₁ % Predicted – Severity Indicator:</b>", sSmall)) elems.append(Spacer(1,2)) elems.append(severity_bar(58, "Moderate Obstruction")) elems.append(Spacer(1, 5)) # BD Reversibility elems.append(colored_header(" Bronchodilator Reversibility", size=9)) bd_data = [ ['Parameter', 'Pre-BD', 'Post-BD', 'Absolute Change', '% Change', 'Significant?'], ['FEV₁ (L)', '1.85', '1.98', '+0.13 L', '+7.0%', 'No (<12% / 200mL)'], ['FVC (L)', '3.62', '3.70', '+0.08 L', '+2.2%', 'No'], ] bd_tbl = Table(bd_data, colWidths=[(PAGE_W-2*MARGIN)/6]*6) bd_tbl.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,0), C_ACCENT), ('FONTNAME',(0,0),(-1,0),'Helvetica-Bold'), ('FONTNAME',(0,1),(-1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),8.5), ('TEXTCOLOR',(0,0),(-1,0), colors.white), ('ROWBACKGROUNDS',(0,1),(-1,-1),[colors.white, C_BAND_BLUE]), ('GRID',(0,0),(-1,-1),0.3,C_GREY_LINE), ('ALIGN',(1,0),(-1,-1),'CENTER'), ('TOPPADDING',(0,0),(-1,-1),4), ('BOTTOMPADDING',(0,0),(-1,-1),4), ('LEFTPADDING',(0,0),(-1,-1),6), ])) elems.append(bd_tbl) elems.append(Spacer(1, 6)) # Interpretation elems.append(colored_header(" Interpretation & Clinical Correlation", size=9)) interp = """ <b>PATTERN:</b> OBSTRUCTIVE VENTILATORY DEFECT – Moderate (Post-BD FEV₁ 62% predicted)<br/><br/> <b>Key Findings:</b><br/> • FEV₁/FVC ratio is significantly reduced (51.1% pre-BD, 53.5% post-BD), both below the lower limit of normal (LLN <70%), confirming airflow obstruction per GOLD/ATS-ERS criteria.<br/> • FEV₁ is reduced at 58% predicted (pre-BD), grading as <b>Moderate</b> obstruction (GOLD Stage II / ATS: 50–64%).<br/> • FVC is relatively preserved (92% predicted), consistent with predominant obstructive rather than restrictive physiology.<br/> • FEF 25–75% is markedly reduced (28% predicted), indicating significant small airway obstruction.<br/> • Gas trapping is evident: TLC elevated (111%), RV elevated (142%), RV/TLC ratio 49.7% (hyperinflation).<br/> • DLCO is reduced (59% predicted), suggesting parenchymal damage consistent with emphysema.<br/> • Bronchodilator response is <b>not significant</b> (FEV₁ change +130 mL, +7.0% – below ATS threshold of ≥200 mL and ≥12%).<br/><br/> <b>Impression:</b> Findings are consistent with <b>Chronic Obstructive Pulmonary Disease (COPD), GOLD Grade 2 (Moderate)</b>. The lack of significant BD reversibility, combined with emphysematous changes (elevated TLC/RV, reduced DLCO), supports a predominantly fixed obstructive pattern. Correlation with high-resolution CT thorax and clinical history recommended. """ elems.append(Paragraph(interp, S('si', fontSize=8.5, leading=14, fontName='Helvetica', borderPadding=6, backColor=colors.HexColor("#FFF5F5"), borderColor=C_RED, borderWidth=0.5, textColor=C_DARK))) elems.append(Spacer(1, 4)) elems.append(Paragraph("Reporting Physician: ___________________________ Signature: ___________________________ Date: 07/08/2026", S('sig', fontSize=8, textColor=colors.grey))) elems.append(PageBreak()) return elems # ═══════════════════════════════════════════════════════════════ # CASE 2: RESTRICTIVE # ═══════════════════════════════════════════════════════════════ def build_case2(): elems = [] hdr = Table([[Paragraph("CASE 2: RESTRICTIVE VENTILATORY DEFECT", S('ch2', fontName='Helvetica-Bold', fontSize=13, textColor=C_HEADER_TEXT, alignment=TA_LEFT)), Paragraph("Idiopathic Pulmonary Fibrosis (IPF)", S('cs2', fontName='Helvetica', fontSize=10, textColor=colors.HexColor("#FFE0C0"), alignment=TA_RIGHT))]], colWidths=[(PAGE_W-2*MARGIN)*0.65, (PAGE_W-2*MARGIN)*0.35]) hdr.setStyle(TableStyle([('BACKGROUND',(0,0),(-1,-1), C_ORANGE), ('TOPPADDING',(0,0),(-1,-1),8), ('BOTTOMPADDING',(0,0),(-1,-1),8), ('LEFTPADDING',(0,0),(0,-1),10), ('RIGHTPADDING',(-1,0),(-1,-1),10)])) elems.append(hdr) elems.append(Spacer(1, 4)) elems.append(colored_header(" Patient Demographics & Clinical Information", size=9)) elems.append(field_row([ ("Name", "Mrs. Sunita Patel"), ("Age / Sex", "64 yrs / Female"), ("Height", "155 cm"), ("Weight", "58 kg"), ("BMI", "24.1 kg/m²"), ("Ethnicity", "South Asian"), ("Referring Physician", "Dr. R. Joshi, DM Resp"), ("Date of Test", "07-Aug-2026"), ("Clinical Indication", "Progressive dyspnoea on exertion, bibasilar crackles, known ILD on HRCT"), ("", ""), ])) elems.append(Spacer(1, 4)) elems.append(colored_header(" Spirometry Results (Pre & Post Bronchodilator)", size=9)) rows = [ ["FVC (L)", "2.70", "1.82", "67%", "1.88", "70%", "↓"], ["FEV₁ (L)", "2.16", "1.62", "75%", "1.66", "77%", "↓"], ["FEV₁/FVC (%)", "80.0", "89.0", "111%","88.3","110%", "—"], ["FEF 25-75% (L/s)", "2.52", "2.18", "87%", "2.22", "88%", "—"], ["PEF (L/s)", "6.38", "5.10", "80%", "5.22", "82%", "—"], ["FEV₆ (L)", "2.68", "1.82", "68%", "1.88", "70%", "↓"], ["Inspiratory Capacity (L)","1.80","1.25","69%","1.28","71%", "↓"], ] elems.append(result_table(rows, "Spirometry Restrictive", abnormal_rows={0,1,5,6})) elems.append(Spacer(1, 4)) elems.append(colored_header(" Lung Volumes & Diffusing Capacity (DLCO)", size=9)) vol_rows = [ ["TLC (L)", "4.28", "3.02", "71%", "—", "—", "↓"], ["RV (L)", "1.58", "1.20", "76%", "—", "—", "↓"], ["FRC (L)", "2.40", "1.65", "69%", "—", "—", "↓"], ["RV/TLC (%)", "36.9", "39.7", "108%","—", "—", "—"], ["DLCO (mL/min/mmHg)", "22.8", "12.5", "55%", "—", "—", "↓"], ["DLCO/VA (KCO)", "5.3", "4.1", "77%", "—", "—", "↓"], ["DLCO/Alv Vol (adj)", "—", "3.9", "74%", "—", "—", "↓"], ] elems.append(result_table(vol_rows, "Volumes Restrictive", abnormal_rows={0,1,2,4,5,6})) elems.append(Spacer(1, 5)) curves = Table([[draw_flow_volume('restrictive'), draw_volume_time('restrictive')]], colWidths=[(PAGE_W-2*MARGIN)/2]*2) curves.setStyle(TableStyle([ ('LEFTPADDING',(0,0),(-1,-1),3), ('RIGHTPADDING',(0,0),(-1,-1),3), ('TOPPADDING',(0,0),(-1,-1),2), ('BOTTOMPADDING',(0,0),(-1,-1),2), ])) elems.append(curves) elems.append(Spacer(1, 4)) elems.append(Paragraph("<b>TLC % Predicted – Severity Indicator (Restrictive):</b>", sSmall)) elems.append(Spacer(1,2)) elems.append(severity_bar(71, "Moderate Restriction")) elems.append(Spacer(1, 5)) # 6MWT elems.append(colored_header(" Supplementary: 6-Minute Walk Test (6MWT)", size=9)) mwt_data = [ ['Parameter', 'Baseline', 'Nadir (Exercise)', 'Recovery 5 min'], ['SpO₂ (%)', '96%', '88%', '94%'], ['Heart Rate (bpm)', '78', '112', '85'], ['Distance walked', '—', '310 m', '—'], ['% Predicted 6MWT', '—', '58%', '—'], ['Dyspnea (Borg)', '0', '5/10', '2/10'], ] mwt_tbl = Table(mwt_data, colWidths=[(PAGE_W-2*MARGIN)/4]*4) mwt_tbl.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,0), C_ACCENT), ('FONTNAME',(0,0),(-1,0),'Helvetica-Bold'), ('FONTNAME',(0,1),(-1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),8.5), ('TEXTCOLOR',(0,0),(-1,0), colors.white), ('ROWBACKGROUNDS',(0,1),(-1,-1),[colors.white, C_BAND_BLUE]), ('GRID',(0,0),(-1,-1),0.3,C_GREY_LINE), ('ALIGN',(1,0),(-1,-1),'CENTER'), ('TOPPADDING',(0,0),(-1,-1),4), ('BOTTOMPADDING',(0,0),(-1,-1),4), ('LEFTPADDING',(0,0),(-1,-1),6), ('TEXTCOLOR',(1,2),(1,2), C_RED), ('FONTNAME',(1,2),(1,2),'Helvetica-Bold'), ])) elems.append(mwt_tbl) elems.append(Spacer(1, 6)) elems.append(colored_header(" Interpretation & Clinical Correlation", size=9)) interp = """ <b>PATTERN:</b> RESTRICTIVE VENTILATORY DEFECT – Moderate (TLC 71% predicted)<br/><br/> <b>Key Findings:</b><br/> • TLC is reduced at 71% predicted, confirming a true restrictive defect (TLC <LLN is the gold standard per ATS/ERS 2022).<br/> • FVC is reduced (67% predicted) with a proportional reduction in FEV₁ (75% predicted).<br/> • FEV₁/FVC is elevated (89%) – normal or supra-normal ratio is characteristic of restriction due to the proportionate reduction of both FEV₁ and FVC with intact elastic recoil.<br/> • FEF 25–75% is preserved, indicating no significant small airway obstruction.<br/> • DLCO is markedly reduced (55% predicted), disproportionate to the degree of restriction, suggesting significant interstitial involvement and reduced alveolar-capillary surface area (consistent with UIP/IPF pattern).<br/> • All lung volumes are reduced (TLC, RV, FRC), typical of parenchymal restrictive disease (vs. neuromuscular, where RV may be preserved or elevated).<br/> • Exertional desaturation on 6MWT (SpO₂ nadir 88%) indicates significant exercise-induced hypoxemia, warranting supplemental O₂ assessment.<br/><br/> <b>Impression:</b> Findings are consistent with <b>Moderately Severe Restrictive Ventilatory Defect</b> with significantly impaired DLCO, most compatible with <b>Idiopathic Pulmonary Fibrosis (IPF)</b> in the appropriate clinical context. Correlation with HRCT (honeycombing/traction bronchiectasis), BAL, and multidisciplinary ILD team discussion recommended. """ elems.append(Paragraph(interp, S('si2', fontSize=8.5, leading=14, fontName='Helvetica', borderPadding=6, backColor=colors.HexColor("#FFF8F0"), borderColor=C_ORANGE, borderWidth=0.5, textColor=C_DARK))) elems.append(Spacer(1, 4)) elems.append(Paragraph("Reporting Physician: ___________________________ Signature: ___________________________ Date: 07/08/2026", S('sig2', fontSize=8, textColor=colors.grey))) elems.append(PageBreak()) return elems # ═══════════════════════════════════════════════════════════════ # CASE 3: MIXED OBSTRUCTIVE-RESTRICTIVE # ═══════════════════════════════════════════════════════════════ def build_case3(): elems = [] hdr = Table([[Paragraph("CASE 3: MIXED OBSTRUCTIVE-RESTRICTIVE DEFECT", S('ch3', fontName='Helvetica-Bold', fontSize=13, textColor=C_HEADER_TEXT, alignment=TA_LEFT)), Paragraph("COPD + Pulmonary Fibrosis (CPFE)", S('cs3', fontName='Helvetica', fontSize=10, textColor=colors.HexColor("#DFC0FF"), alignment=TA_RIGHT))]], colWidths=[(PAGE_W-2*MARGIN)*0.65, (PAGE_W-2*MARGIN)*0.35]) hdr.setStyle(TableStyle([('BACKGROUND',(0,0),(-1,-1), colors.HexColor("#6A0DAD")), ('TOPPADDING',(0,0),(-1,-1),8), ('BOTTOMPADDING',(0,0),(-1,-1),8), ('LEFTPADDING',(0,0),(0,-1),10), ('RIGHTPADDING',(-1,0),(-1,-1),10)])) elems.append(hdr) elems.append(Spacer(1, 4)) elems.append(colored_header(" Patient Demographics & Clinical Information", size=9)) elems.append(field_row([ ("Name", "Mr. Gopal Singh"), ("Age / Sex", "72 yrs / Male"), ("Height", "170 cm"), ("Weight", "68 kg"), ("BMI", "23.5 kg/m²"), ("Ethnicity", "South Asian"), ("Referring Physician", "Dr. K. Rao, DM Pulm"), ("Date of Test", "07-Aug-2026"), ("Clinical Indication", "Severe dyspnoea, >50 pack-year smoking, bibasilar fibrosis + upper lobe emphysema on HRCT"), ("", ""), ])) elems.append(Spacer(1, 4)) elems.append(colored_header(" Spirometry Results (Pre & Post Bronchodilator)", size=9)) rows = [ ["FVC (L)", "3.55", "2.20", "62%", "2.28", "64%", "↓"], ["FEV₁ (L)", "2.90", "1.30", "45%", "1.40", "48%", "↓"], ["FEV₁/FVC (%)", "81.7", "59.1", "72%", "61.4", "75%", "↓"], ["FEF 25-75% (L/s)", "3.12", "0.72", "23%", "0.82", "26%", "↓"], ["PEF (L/s)", "8.20", "4.45", "54%", "4.80", "59%", "↓"], ["FEV₆ (L)", "3.50", "2.18", "62%", "2.25", "64%", "↓"], ["FEV₁/FEV₆ (%)", "82.9", "59.6", "72%", "62.2", "75%", "↓"], ["Inspiratory Capacity (L)","2.48","1.42","57%","1.52","61%", "↓"], ] elems.append(result_table(rows, "Spirometry Mixed", abnormal_rows={0,1,2,3,4,5,6,7})) elems.append(Spacer(1, 4)) elems.append(colored_header(" Lung Volumes & Diffusing Capacity (DLCO)", size=9)) vol_rows = [ ["TLC (L)", "5.80", "4.35", "75%", "—", "—", "↓"], ["RV (L)", "2.25", "2.15", "96%", "—", "—", "—"], ["FRC (L)", "3.20", "2.85", "89%", "—", "—", "—"], ["RV/TLC (%)", "38.8", "49.4", "127%","—", "—", "↑"], ["DLCO (mL/min/mmHg)", "25.2", "9.8", "39%", "—", "—", "↓"], ["DLCO/VA (KCO)", "4.3", "2.2", "51%", "—", "—", "↓"], ["Va (alveolar vol, L)","—", "4.45", "—", "—", "—", "—"], ] elems.append(result_table(vol_rows, "Volumes Mixed", abnormal_rows={0,3,4,5})) elems.append(Spacer(1, 5)) curves = Table([[draw_flow_volume('mixed'), draw_volume_time('mixed')]], colWidths=[(PAGE_W-2*MARGIN)/2]*2) curves.setStyle(TableStyle([ ('LEFTPADDING',(0,0),(-1,-1),3), ('RIGHTPADDING',(0,0),(-1,-1),3), ('TOPPADDING',(0,0),(-1,-1),2), ('BOTTOMPADDING',(0,0),(-1,-1),2), ])) elems.append(curves) elems.append(Spacer(1, 4)) elems.append(Paragraph("<b>FEV₁ % Predicted – Severity Indicator:</b>", sSmall)) elems.append(Spacer(1,2)) elems.append(severity_bar(45, "Severe Mixed Pattern")) elems.append(Spacer(1, 5)) # Comparison table elems.append(colored_header(" Pattern Comparison Summary", size=9)) comp_data = [ ['Parameter', 'Normal Range', 'Obstructive (Case 1)', 'Restrictive (Case 2)', 'Mixed (Case 3)'], ['FEV₁/FVC', '≥ LLN (≥70%)', '↓↓ (51%)', 'Normal/↑ (89%)', '↓↓ (59%)'], ['FVC % pred', '≥ 80%', 'Normal (92%)', '↓↓ (67%)', '↓↓ (62%)'], ['FEV₁ % pred', '≥ 80%', '↓↓ (58%)', '↓ (75%)', '↓↓↓ (45%)'], ['TLC', '≥ LLN', 'Normal/↑ (111%)', '↓↓ (71%)', '↓ (75%)'], ['RV / RV:TLC', 'Normal', '↑↑ (142%/49.7%)', 'Normal (76%/39.7%)', 'Normal RV, ↑ ratio'], ['DLCO', '≥ 80%', '↓ (59%)', '↓↓ (55%)', '↓↓↓ (39%)'], ['BD Response', '—', 'No (COPD)', 'N/A', 'Partial'], ['Key Diagnosis', '—', 'COPD (GOLD 2)', 'IPF / ILD', 'CPFE Syndrome'], ] comp_tbl = Table(comp_data, colWidths=[35*mm, 25*mm, 36*mm, 36*mm, 36*mm]) comp_tbl.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,0), C_HEADER_BG), ('FONTNAME',(0,0),(-1,0),'Helvetica-Bold'), ('FONTSIZE',(0,0),(-1,-1),7.8), ('TEXTCOLOR',(0,0),(-1,0), colors.white), ('FONTNAME',(0,1),(0,-1),'Helvetica-Bold'), ('FONTNAME',(1,1),(-1,-1),'Helvetica'), ('ROWBACKGROUNDS',(0,1),(-1,-1),[colors.white, C_BAND_BLUE]), ('GRID',(0,0),(-1,-1),0.3,C_GREY_LINE), ('ALIGN',(1,0),(-1,-1),'CENTER'), ('VALIGN',(0,0),(-1,-1),'MIDDLE'), ('TOPPADDING',(0,0),(-1,-1),4), ('BOTTOMPADDING',(0,0),(-1,-1),4), ('LEFTPADDING',(0,0),(-1,-1),4), ])) elems.append(comp_tbl) elems.append(Spacer(1, 6)) elems.append(colored_header(" Interpretation & Clinical Correlation", size=9)) interp = """ <b>PATTERN:</b> MIXED OBSTRUCTIVE-RESTRICTIVE VENTILATORY DEFECT – Severe (FEV₁ 45% predicted)<br/><br/> <b>Key Findings:</b><br/> • FEV₁/FVC is reduced (59%), confirming obstructive physiology, AND TLC is reduced (75%) confirming a coexisting restrictive component – this dual abnormality defines a true mixed pattern (per ATS/ERS: obstruction + TLC <LLN).<br/> • FEV₁ is severely reduced at 45% predicted, indicating severe overall ventilatory impairment.<br/> • FVC is reduced (62%) – reduced out of proportion to the obstructive process alone, reflecting the added restrictive component.<br/> • FEF 25–75% is markedly reduced (23%), indicating severe small airway disease.<br/> • Despite reduced TLC (75%), RV/TLC ratio is elevated (49.4%), indicating gas trapping superimposed on restriction – characteristic of CPFE (Combined Pulmonary Fibrosis and Emphysema).<br/> • DLCO is severely reduced (39% predicted), indicating extensive loss of gas-exchanging surface – typical of CPFE where emphysema and fibrosis both devastate the alveolar-capillary bed.<br/> • Note: In CPFE, spirometry and lung volumes may be <i>deceptively near-normal</i> due to the opposing effects of emphysema (increasing lung volumes) and fibrosis (decreasing lung volumes); DLCO is therefore the most sensitive indicator of severity.<br/><br/> <b>Impression:</b> Findings are consistent with <b>CPFE (Combined Pulmonary Fibrosis and Emphysema) Syndrome</b> with severe mixed obstructive-restrictive defect and severely reduced DLCO. This syndrome carries a high risk of pulmonary hypertension (echo recommended), lung cancer, and acute exacerbation. Multidisciplinary ILD/COPD team discussion and palliative care referral should be considered. """ elems.append(Paragraph(interp, S('si3', fontSize=8.5, leading=14, fontName='Helvetica', borderPadding=6, backColor=colors.HexColor("#F5F0FF"), borderColor=colors.HexColor("#6A0DAD"), borderWidth=0.5, textColor=C_DARK))) elems.append(Spacer(1, 4)) elems.append(Paragraph("Reporting Physician: ___________________________ Signature: ___________________________ Date: 07/08/2026", S('sig3', fontSize=8, textColor=colors.grey))) elems.append(PageBreak()) return elems # ═══════════════════════════════════════════════════════════════ # FINAL REFERENCE PAGE # ═══════════════════════════════════════════════════════════════ def build_reference(): elems = [] elems.append(colored_header(" Quick Reference: PFT Interpretation Algorithm", size=10)) elems.append(Spacer(1,6)) algo_data = [ ['Step', 'Question', 'Result → Next Step'], ['1', 'Is FEV₁/FVC < LLN?', 'YES → Obstructive component present\nNO → Proceed to Step 2'], ['2', 'Is FVC low (< LLN)?', 'YES → Measure TLC\nNO → Normal spirometry'], ['3', 'Is TLC < LLN?', 'YES → Restrictive defect confirmed\nNO → Non-specific pattern'], ['4', 'Is FEV₁/FVC LOW + TLC LOW?', 'YES → Mixed obstructive-restrictive\nNO → Pure obstructive'], ['5', 'Assess DLCO', '↓ with obstruction → emphysema/vascular\n↓ with restriction → ILD/fibrosis\n↓↓↓ with mixed → CPFE'], ['6', 'BD reversibility', '≥200 mL + ≥12% FEV₁ increase → Significant response (asthma-like)\n< threshold → Fixed obstruction (COPD)'], ] algo_tbl = Table(algo_data, colWidths=[12*mm, 58*mm, PAGE_W-2*MARGIN-70*mm]) algo_tbl.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,0), C_HEADER_BG), ('FONTNAME',(0,0),(-1,0),'Helvetica-Bold'), ('FONTNAME',(0,1),(-1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),8.5), ('TEXTCOLOR',(0,0),(-1,0), colors.white), ('FONTNAME',(0,1),(0,-1),'Helvetica-Bold'), ('TEXTCOLOR',(0,1),(0,-1), C_ACCENT), ('ROWBACKGROUNDS',(0,1),(-1,-1),[colors.white, C_BAND_BLUE]), ('GRID',(0,0),(-1,-1),0.3,C_GREY_LINE), ('VALIGN',(0,0),(-1,-1),'MIDDLE'), ('TOPPADDING',(0,0),(-1,-1),5), ('BOTTOMPADDING',(0,0),(-1,-1),5), ('LEFTPADDING',(0,0),(-1,-1),6), ])) elems.append(algo_tbl) elems.append(Spacer(1, 8)) elems.append(colored_header(" Normal Reference Values (Adults, Sea Level)", size=10)) elems.append(Spacer(1,4)) norm_data = [ ['Parameter', 'Abbreviation', 'Normal (Adults)', 'Clinical Significance'], ['Forced Vital Capacity', 'FVC', '≥ 80% predicted', 'Total exhaled volume – reduced in restriction and severe obstruction'], ['Forced Expiratory Vol 1s', 'FEV₁', '≥ 80% predicted', 'Key indicator of obstructive severity (GOLD grading)'], ['FEV₁/FVC Ratio', 'FEV₁/FVC', '≥ 0.70 (fixed); ≥ LLN (statistical)', 'Diagnostic criterion for obstruction'], ['Forced Expiratory Flow 25-75%', 'FEF₂₅₋₇₅', '≥ 65% predicted', 'Small airway function; highly variable'], ['Peak Expiratory Flow', 'PEF', '≥ 80% predicted', 'Effort-dependent; useful for asthma monitoring'], ['Total Lung Capacity', 'TLC', '≥ LLN (80-120%)', 'Gold standard for restriction diagnosis'], ['Residual Volume', 'RV', '≥ LLN', 'Elevated in gas trapping/emphysema'], ['RV/TLC Ratio', 'RV/TLC', '< 40%', 'Indicator of gas trapping when >40-45%'], ['Diffusing Capacity CO', 'DLCO', '≥ 80% predicted', 'Reflects alveolar-capillary integrity'], ['Carbon Monoxide transfer coeff.', 'KCO (DLCO/VA)', '≥ 80% predicted', 'Adjusted DLCO for alveolar volume'], ['Functional Residual Capacity', 'FRC', '≥ LLN', 'Lung volume at end of quiet expiration'], ] norm_tbl = Table(norm_data, colWidths=[50*mm, 20*mm, 40*mm, PAGE_W-2*MARGIN-110*mm]) norm_tbl.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,0), C_ACCENT), ('FONTNAME',(0,0),(-1,0),'Helvetica-Bold'), ('FONTNAME',(0,1),(-1,-1),'Helvetica'), ('FONTSIZE',(0,0),(-1,-1),7.8), ('TEXTCOLOR',(0,0),(-1,0), colors.white), ('ROWBACKGROUNDS',(0,1),(-1,-1),[colors.white, C_BAND_BLUE]), ('GRID',(0,0),(-1,-1),0.3,C_GREY_LINE), ('VALIGN',(0,0),(-1,-1),'MIDDLE'), ('TOPPADDING',(0,0),(-1,-1),4), ('BOTTOMPADDING',(0,0),(-1,-1),4), ('LEFTPADDING',(0,0),(-1,-1),5), ])) elems.append(norm_tbl) elems.append(Spacer(1, 8)) elems.append(colored_header(" Sources & Standards", size=9)) refs = [ "1. Graham BL et al. Standardization of Spirometry 2019 Update. Am J Respir Crit Care Med. 2019;200(8):e70-e88.", "2. Pellegrino R et al. Interpretive strategies for lung function tests. Eur Respir J. 2005;26(5):948-968.", "3. GOLD 2023 Report. Global Strategy for COPD Prevention, Diagnosis and Management.", "4. Raghu G et al. Diagnosis of Idiopathic Pulmonary Fibrosis. ATS/ERS/JRS/ALAT Guidelines 2022.", "5. Hansell DM et al. Fleischner Society: Idiomatic Terms for Thoracic Imaging. Radiology. 2008.", "6. Quanjer PH et al. Multi-ethnic reference values for spirometry. Eur Respir J. 2012;40(6):1324-1343 (GLI-2012).", "7. Barash et al. Clinical Anesthesia 9e. Table 15-2: PFT in Restrictive and Obstructive Lung Disease.", "8. Murray & Nadel's Textbook of Respiratory Medicine. 'OBSTRUCTIVE VENTILATORY DEFECT' & 'GENERAL APPROACH TO INTERPRETATION'.", "9. Fishman's Pulmonary Diseases and Disorders. 'Interpretation Scheme and Classification of Abnormal Patterns'.", ] for r in refs: elems.append(Paragraph(r, S('ref', fontSize=7.5, leading=11, fontName='Helvetica', textColor=colors.HexColor("#444"), leftIndent=10))) elems.append(Spacer(1, 2)) return elems # ═══════════════════════════════════════════════════════════════ # PAGE TEMPLATE (header + footer on each page) # ═══════════════════════════════════════════════════════════════ def add_page_decorations(canvas, doc): canvas.saveState() w, h = A4 # Top thin bar canvas.setFillColor(C_HEADER_BG) canvas.rect(0, h-8*mm, w, 6*mm, fill=1, stroke=0) canvas.setFillColor(colors.white) canvas.setFont('Helvetica-Bold', 7) canvas.drawString(MARGIN, h-5.5*mm, "PULMONARY FUNCTION TEST REPORT") canvas.setFont('Helvetica', 7) canvas.drawRightString(w-MARGIN, h-5.5*mm, "Dept. of Pulmonary Medicine | August 2026") # Bottom bar canvas.setFillColor(C_HEADER_BG) canvas.rect(0, 0, w, 7*mm, fill=1, stroke=0) canvas.setFillColor(colors.white) canvas.setFont('Helvetica', 6.5) canvas.drawString(MARGIN, 2.5*mm, "CONFIDENTIAL MEDICAL DOCUMENT — For clinical use only. " "Interpret in conjunction with clinical findings.") canvas.setFont('Helvetica-Bold', 7) canvas.drawRightString(w-MARGIN, 2.5*mm, f"Page {doc.page}") canvas.restoreState() # ═══════════════════════════════════════════════════════════════ # BUILD PDF # ═══════════════════════════════════════════════════════════════ out = "/tmp/workspace/pft-reports/PFT_Reports_Complete.pdf" doc = SimpleDocTemplate(out, pagesize=A4, leftMargin=MARGIN, rightMargin=MARGIN, topMargin=12*mm, bottomMargin=12*mm, title="Pulmonary Function Test Reports", author="Dept. of Pulmonary Medicine", subject="PFT: Obstructive, Restrictive, Mixed") story = [] story += build_cover() story += build_case1() story += build_case2() story += build_case3() story += build_reference() doc.build(story, onFirstPage=add_page_decorations, onLaterPages=add_page_decorations) print(f"PDF created: {out}") import os size = os.path.getsize(out) print(f"File size: {size/1024:.1f} KB") PYEOF
python3 -c " import pdfplumber with pdfplumber.open('/tmp/workspace/pft-reports/PFT_Reports_Complete.pdf') as pdf: print(f'Total pages: {len(pdf.pages)}') for i, page in enumerate(pdf.pages[:3]): txt = page.extract_text() print(f'--- Page {i+1} (first 200 chars) ---') print(txt[:200] if txt else '[no text]') "
PFT Reports Complete
PDF Document · PDF