~/hrct-guide/generate_hrct_guide.py
"""
HRCT & CECT Chest Visual Reference Guide
Multi-page PDF using reportlab with visual pattern diagrams drawn programmatically
"""
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import cm, mm
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
TableStyle, HRFlowable, KeepTogether, PageBreak)
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.graphics.shapes import (Drawing, Rect, Circle, Line, String,
Ellipse, Polygon, PolyLine, Group)
from reportlab.graphics import renderPDF
from reportlab.platypus import Flowable
from reportlab.lib.colors import (HexColor, white, black, grey, lightgrey,
darkgrey, Color)
import math
# ── Colour palette ─────────────────────────────────────────────────────────────
NAVY = HexColor('#0D2B45')
TEAL = HexColor('#1B6CA8')
CYAN = HexColor('#3ABFD4')
GOLD = HexColor('#F5A623')
ORANGE = HexColor('#E07B3A')
RED = HexColor('#C0392B')
GREEN = HexColor('#27AE60')
PURPLE = HexColor('#8E44AD')
LBLUE = HexColor('#D6EAF8')
LGOLD = HexColor('#FEF9E7')
LGREEN = HexColor('#E9F7EF')
LRED = HexColor('#FDEDEC')
LPURPLE = HexColor('#F5EEF8')
LGREY = HexColor('#F2F3F4')
DARKGREY = HexColor('#2C3E50')
MIDGREY = HexColor('#7F8C8D')
PAGEBG = HexColor('#FAFBFC')
# ── Lung grey scale (CT appearance) ────────────────────────────────────────────
CT_AIRSPACE = HexColor('#0A0A0A') # almost black – normal aerated lung
CT_GGO = HexColor('#505050') # dark grey – ground glass
CT_CONSOL = HexColor('#C8C8C8') # light grey – consolidation / soft tissue
CT_VESSEL = HexColor('#FFFFFF') # white – vessels / enhanced structures
CT_HONEY = HexColor('#1A1A1A') # very dark – honeycombing cysts
CT_FIBROSIS = HexColor('#888888') # medium grey – reticular fibrosis
W, H = A4 # 595 x 842 pt
# ── Styles ─────────────────────────────────────────────────────────────────────
def make_styles():
base = getSampleStyleSheet()
def s(name, parent='Normal', **kw):
return ParagraphStyle(name, parent=base[parent], **kw)
styles = {
'cover_title': s('CoverTitle',
fontName='Helvetica-Bold', fontSize=28, textColor=white,
leading=34, alignment=TA_CENTER, spaceAfter=8),
'cover_sub': s('CoverSub',
fontName='Helvetica', fontSize=14, textColor=HexColor('#D6EAF8'),
leading=18, alignment=TA_CENTER, spaceAfter=4),
'cover_tag': s('CoverTag',
fontName='Helvetica-Oblique', fontSize=10, textColor=HexColor('#AED6F1'),
leading=14, alignment=TA_CENTER),
'section_h': s('SectionH',
fontName='Helvetica-Bold', fontSize=16, textColor=white,
leading=20, alignment=TA_LEFT, spaceAfter=0, spaceBefore=0),
'page_title': s('PageTitle',
fontName='Helvetica-Bold', fontSize=18, textColor=NAVY,
leading=22, alignment=TA_LEFT, spaceBefore=4, spaceAfter=6),
'sub_h': s('SubH',
fontName='Helvetica-Bold', fontSize=12, textColor=TEAL,
leading=15, spaceBefore=8, spaceAfter=3),
'body': s('Body',
fontName='Helvetica', fontSize=8.5, textColor=DARKGREY,
leading=13, spaceBefore=1, spaceAfter=1),
'small': s('Small',
fontName='Helvetica', fontSize=7.5, textColor=MIDGREY,
leading=11, spaceBefore=0, spaceAfter=0),
'label': s('Label',
fontName='Helvetica-Bold', fontSize=7.5, textColor=NAVY,
leading=10, alignment=TA_CENTER),
'caption': s('Caption',
fontName='Helvetica-Oblique', fontSize=7.5, textColor=MIDGREY,
leading=10, alignment=TA_CENTER, spaceAfter=4),
'table_h': s('TableH',
fontName='Helvetica-Bold', fontSize=8, textColor=white,
leading=11, alignment=TA_CENTER),
'table_cell': s('TableCell',
fontName='Helvetica', fontSize=8, textColor=DARKGREY,
leading=11, alignment=TA_LEFT),
'bullet': s('Bullet',
fontName='Helvetica', fontSize=8.5, textColor=DARKGREY,
leading=13, leftIndent=12, firstLineIndent=-8, spaceAfter=2),
'footer': s('Footer',
fontName='Helvetica', fontSize=7, textColor=MIDGREY,
alignment=TA_CENTER),
'tip_text': s('TipText',
fontName='Helvetica', fontSize=8, textColor=HexColor('#1A5276'),
leading=12),
}
return styles
ST = make_styles()
# ── Helper Flowables ────────────────────────────────────────────────────────────
class ColorBand(Flowable):
"""Full-width coloured header band"""
def __init__(self, text, bg=TEAL, fg=white, height=28, fs=13, bold=True):
super().__init__()
self.text = text
self.bg = bg
self.fg = fg
self.height = height
self.fs = fs
self.bold = bold
self.width = W - 3*cm
def wrap(self, aw, ah):
self.width = aw
return aw, self.height
def draw(self):
c = self.canv
c.setFillColor(self.bg)
c.roundRect(0, 0, self.width, self.height, 4, fill=1, stroke=0)
fn = 'Helvetica-Bold' if self.bold else 'Helvetica'
c.setFont(fn, self.fs)
c.setFillColor(self.fg)
c.drawString(10, self.height/2 - self.fs*0.35, self.text)
class TipBox(Flowable):
def __init__(self, text, bg=LBLUE, border=TEAL, label='KEY POINT', width=None):
super().__init__()
self.text = text
self.bg = bg
self.border = border
self.label = label
self._width = width or (W - 3*cm)
self.height = 40
def wrap(self, aw, ah):
self._width = aw
return aw, self.height
def draw(self):
c = self.canv
c.setFillColor(self.bg)
c.roundRect(0, 2, self._width, self.height-4, 5, fill=1, stroke=0)
c.setStrokeColor(self.border)
c.setLineWidth(1.5)
c.roundRect(0, 2, self._width, self.height-4, 5, fill=0, stroke=1)
# label
c.setFont('Helvetica-Bold', 7)
c.setFillColor(self.border)
c.drawString(8, self.height - 12, self.label)
c.setFont('Helvetica', 8)
c.setFillColor(DARKGREY)
c.drawString(8, 8, self.text)
# ── CT Pattern Drawing Functions ───────────────────────────────────────────────
def draw_lung_outline(d, x, y, w, h, fill_color=CT_AIRSPACE, label=None,
border_color=HexColor('#444444'), label_color=white):
"""Draw a simplified lung circle with label"""
d.add(Circle(x + w/2, y + h/2, min(w, h)/2 - 2,
fillColor=fill_color, strokeColor=border_color, strokeWidth=1))
if label:
d.add(String(x + w/2, y + h/2 - 4, label,
fontSize=7, fillColor=label_color,
textAnchor='middle', fontName='Helvetica-Bold'))
def draw_vessel(d, x1, y1, x2, y2, color=CT_VESSEL, width=2):
d.add(Line(x1, y1, x2, y2, strokeColor=color, strokeWidth=width))
def pattern_normal(w=120, h=100):
"""Normal HRCT - dark lung with thin branching vessels"""
d = Drawing(w, h)
# Background circle
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# Main bronchus/artery
d.add(Line(w/2, h*0.9, w/2, h*0.55, strokeColor=CT_VESSEL, strokeWidth=2.5))
# Branching vessels
d.add(Line(w/2, h*0.75, w*0.3, h*0.5, strokeColor=CT_VESSEL, strokeWidth=1.5))
d.add(Line(w/2, h*0.75, w*0.7, h*0.5, strokeColor=CT_VESSEL, strokeWidth=1.5))
d.add(Line(w*0.3, h*0.5, w*0.2, h*0.3, strokeColor=CT_VESSEL, strokeWidth=1))
d.add(Line(w*0.3, h*0.5, w*0.4, h*0.3, strokeColor=CT_VESSEL, strokeWidth=1))
d.add(Line(w*0.7, h*0.5, w*0.6, h*0.3, strokeColor=CT_VESSEL, strokeWidth=1))
d.add(Line(w*0.7, h*0.5, w*0.8, h*0.3, strokeColor=CT_VESSEL, strokeWidth=1))
# Label
d.add(String(w/2, 6, 'NORMAL', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_ggo(w=120, h=100):
"""Ground Glass Opacity - hazy grey overlay"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# GGO patches
for cx, cy, r in [(w*0.45, h*0.6, 22), (w*0.55, h*0.45, 18), (w*0.35, h*0.4, 15)]:
d.add(Circle(cx, cy, r, fillColor=CT_GGO, strokeColor=None, strokeWidth=0))
# Vessels visible through GGO
d.add(Line(w/2, h*0.85, w/2, h*0.5, strokeColor=CT_VESSEL, strokeWidth=2))
d.add(Line(w/2, h*0.7, w*0.35, h*0.5, strokeColor=CT_VESSEL, strokeWidth=1.5))
d.add(String(w/2, 6, 'GROUND GLASS', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_consolidation(w=120, h=100):
"""Consolidation - dense opacification"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# Consolidated area (lower/peripheral)
pts = [w*0.2, h*0.25, w*0.8, h*0.25, w*0.85, h*0.65, w*0.15, h*0.65]
d.add(Polygon(pts, fillColor=CT_CONSOL, strokeColor=None))
# Air bronchogram - dark bronchi within consolidation
d.add(Line(w/2, h*0.6, w/2, h*0.3, strokeColor=CT_AIRSPACE, strokeWidth=2))
d.add(Line(w/2, h*0.45, w*0.35, h*0.3, strokeColor=CT_AIRSPACE, strokeWidth=1.5))
d.add(Line(w/2, h*0.45, w*0.65, h*0.3, strokeColor=CT_AIRSPACE, strokeWidth=1.5))
d.add(String(w/2, 6, 'CONSOLIDATION', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_reticulation(w=120, h=100):
"""Reticular pattern - septal thickening / fibrosis"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# Reticular lines (grid-like)
line_c = HexColor('#AAAAAA')
for i in range(5):
y = h*0.25 + i*h*0.12
d.add(Line(w*0.15, y, w*0.85, y, strokeColor=line_c, strokeWidth=0.8))
for i in range(5):
x = w*0.2 + i*w*0.14
d.add(Line(x, h*0.2, x, h*0.8, strokeColor=line_c, strokeWidth=0.8))
# Subpleural accentuation
d.add(Circle(w/2, h/2, min(w,h)/2-8,
fillColor=None, strokeColor=HexColor('#888888'), strokeWidth=1.5))
d.add(String(w/2, 6, 'RETICULATION', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_honeycombing(w=120, h=100):
"""Honeycombing - clustered cystic spaces, subpleural"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# Subpleural honeycomb cysts
cyst_c = CT_HONEY
wall_c = HexColor('#BBBBBB')
positions = [
(w*0.65, h*0.28), (w*0.75, h*0.35), (w*0.72, h*0.48),
(w*0.62, h*0.55), (w*0.52, h*0.50), (w*0.58, h*0.38),
(w*0.68, h*0.62), (w*0.78, h*0.55),
]
for cx, cy in positions:
r = 7
d.add(Circle(cx, cy, r, fillColor=cyst_c, strokeColor=wall_c, strokeWidth=1.5))
d.add(String(w/2, 6, 'HONEYCOMBING', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_nodules(w=120, h=100, nodule_type='random'):
"""Nodular pattern"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
if nodule_type == 'centrilobular':
positions = [(w*0.3, h*0.65), (w*0.5, h*0.7), (w*0.7, h*0.65),
(w*0.25, h*0.45), (w*0.5, h*0.5), (w*0.75, h*0.45),
(w*0.35, h*0.3), (w*0.65, h*0.3)]
nc = HexColor('#DDDDDD')
elif nodule_type == 'perilymphatic':
positions = [(w*0.35, h*0.7), (w*0.5, h*0.75), (w*0.65, h*0.7),
(w*0.5, h*0.55), (w*0.3, h*0.4), (w*0.7, h*0.4)]
nc = HexColor('#EEEEEE')
else: # random
positions = [(w*0.25, h*0.7), (w*0.5, h*0.65), (w*0.72, h*0.7),
(w*0.3, h*0.45), (w*0.6, h*0.5), (w*0.75, h*0.35),
(w*0.4, h*0.28), (w*0.55, h*0.35)]
nc = HexColor('#FFFFFF')
for cx, cy in positions:
d.add(Circle(cx, cy, 5, fillColor=nc, strokeColor=None))
label = {'centrilobular': 'CENTRILOBULAR', 'perilymphatic': 'PERILYMPHATIC',
'random': 'RANDOM'}.get(nodule_type, 'NODULES')
d.add(String(w/2, 6, label, fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_emphysema(w=120, h=100):
"""Centrilobular emphysema - bullous low attenuation areas"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# Emphysematous spaces (slightly darker than background, no walls)
emp_c = HexColor('#050505')
for cx, cy, r in [(w*0.35, h*0.65, 12), (w*0.55, h*0.6, 10),
(w*0.45, h*0.45, 14), (w*0.65, h*0.5, 11),
(w*0.3, h*0.45, 9)]:
d.add(Circle(cx, cy, r, fillColor=emp_c, strokeColor=HexColor('#1A1A1A'), strokeWidth=0.3))
# Centrilobular artery dot in center of some
for cx, cy in [(w*0.35, h*0.65), (w*0.45, h*0.45)]:
d.add(Circle(cx, cy, 2, fillColor=HexColor('#CCCCCC'), strokeColor=None))
d.add(String(w/2, 6, 'EMPHYSEMA', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_bronchiectasis(w=120, h=100):
"""Bronchiectasis - signet ring sign"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# Multiple signet ring signs
for cx, cy, br, ar in [(w*0.35, h*0.65, 10, 6),
(w*0.6, h*0.6, 9, 5),
(w*0.45, h*0.45, 11, 7)]:
# Dilated bronchus (ring)
d.add(Circle(cx, cy, br, fillColor=CT_AIRSPACE,
strokeColor=HexColor('#BBBBBB'), strokeWidth=2))
# Adjacent artery (solid)
d.add(Circle(cx + br + ar + 1, cy, ar,
fillColor=HexColor('#BBBBBB'), strokeColor=None))
d.add(String(w/2, 6, 'BRONCHIECTASIS', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_crazy_paving(w=120, h=100):
"""Crazy paving - GGO + septal thickening"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_GGO, strokeColor=HexColor('#333333'), strokeWidth=1))
# Superimposed lines (septal thickening)
line_c = HexColor('#CCCCCC')
for i in range(6):
y = h*0.2 + i*h*0.11
d.add(Line(w*0.15, y, w*0.85, y, strokeColor=line_c, strokeWidth=1.2))
for i in range(5):
x = w*0.2 + i*w*0.15
d.add(Line(x, h*0.15, x, h*0.85, strokeColor=line_c, strokeWidth=1.2))
d.add(String(w/2, 6, 'CRAZY PAVING', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_mosaic(w=120, h=100):
"""Mosaic attenuation - air trapping"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
# Alternating dark/grey lobules
lobule_positions = [
(w*0.35, h*0.65, HexColor('#050505')), # very dark = air trapped
(w*0.55, h*0.65, HexColor('#383838')), # slightly brighter
(w*0.25, h*0.50, HexColor('#383838')),
(w*0.45, h*0.50, HexColor('#050505')),
(w*0.65, h*0.50, HexColor('#383838')),
(w*0.35, h*0.35, HexColor('#050505')),
(w*0.55, h*0.35, HexColor('#383838')),
]
for cx, cy, fc in lobule_positions:
d.add(Rect(cx-12, cy-8, 22, 16, fillColor=fc, strokeColor=HexColor('#2A2A2A'), strokeWidth=0.5))
d.add(String(w/2, 6, 'MOSAIC/AIR TRAP', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
def pattern_mass(w=120, h=100):
"""Pulmonary mass with spiculation"""
d = Drawing(w, h)
d.add(Circle(w/2, h/2, min(w,h)/2-3,
fillColor=CT_AIRSPACE, strokeColor=HexColor('#333333'), strokeWidth=1))
cx, cy = w*0.5, h*0.55
r = 18
# Spicules
for angle_deg in range(0, 360, 30):
angle = math.radians(angle_deg)
d.add(Line(cx + r*math.cos(angle), cy + r*math.sin(angle),
cx + (r+8)*math.cos(angle), cy + (r+8)*math.sin(angle),
strokeColor=HexColor('#AAAAAA'), strokeWidth=0.8))
# Mass
d.add(Circle(cx, cy, r, fillColor=HexColor('#AAAAAA'), strokeColor=HexColor('#DDDDDD'), strokeWidth=0.5))
d.add(String(w/2, 6, 'SPICULATED MASS', fontSize=7, fillColor=CT_VESSEL,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
# ── Secondary Lobule Diagram ────────────────────────────────────────────────────
def draw_secondary_lobule(w=300, h=220):
d = Drawing(w, h)
# Background
d.add(Rect(0, 0, w, h, fillColor=LGREY, strokeColor=None))
# Hexagonal lobule outline
cx, cy = w*0.4, h*0.52
side = 60
pts = []
for i in range(6):
angle = math.radians(60*i - 30)
pts.extend([cx + side*math.cos(angle), cy + side*math.sin(angle)])
d.add(Polygon(pts, fillColor=HexColor('#E8F4FD'), strokeColor=TEAL, strokeWidth=2))
# Centrilobular artery (center)
d.add(Circle(cx, cy, 5, fillColor=RED, strokeColor=None))
# Centrilobular bronchiole
d.add(Circle(cx+8, cy+3, 4, fillColor=CT_AIRSPACE, strokeColor=HexColor('#888888'), strokeWidth=1.5))
# Interlobular septa with veins
for i in range(6):
angle = math.radians(60*i - 30)
x1 = cx + 5*math.cos(angle)
y1 = cy + 5*math.sin(angle)
x2 = cx + side*math.cos(angle)
y2 = cy + side*math.sin(angle)
d.add(Line(x1, y1, x2, y2, strokeColor=PURPLE, strokeWidth=2))
# Vein along septa
d.add(Circle(cx + (side*0.6)*math.cos(angle),
cy + (side*0.6)*math.sin(angle),
3, fillColor=PURPLE, strokeColor=None))
# Alveolar clusters
for i in range(6):
angle = math.radians(60*i)
ax = cx + 30*math.cos(angle)
ay = cy + 30*math.sin(angle)
d.add(Circle(ax, ay, 7, fillColor=HexColor('#BDE3F0'),
strokeColor=HexColor('#7FB3C8'), strokeWidth=0.8))
# Labels
d.add(String(cx+12, cy+8, 'Centrilobular', fontSize=6.5, fillColor=RED,
fontName='Helvetica-Bold'))
d.add(String(cx+12, cy+1, 'artery + bronchiole', fontSize=6, fillColor=RED))
# Legend panel on right
lx = w*0.72
items = [
(RED, 'Centrilobular artery'),
(HexColor('#0A0A0A'), 'Centrilobular bronchiole'),
(PURPLE, 'Interlobular septum / vein'),
(HexColor('#7FB3C8'), 'Alveolar sacs'),
]
legend_y = h*0.85
for fc, lbl in items:
d.add(Circle(lx, legend_y, 5, fillColor=fc, strokeColor=MIDGREY, strokeWidth=0.5))
d.add(String(lx+10, legend_y-4, lbl, fontSize=6.5, fillColor=DARKGREY))
legend_y -= 16
d.add(String(lx-5, h*0.93, 'Legend:', fontSize=7, fillColor=DARKGREY, fontName='Helvetica-Bold'))
d.add(String(cx - 20, 8, 'Secondary Pulmonary Lobule', fontSize=8,
fillColor=TEAL, fontName='Helvetica-Bold', textAnchor='middle'))
return d
# ── UIP Pattern Diagram ────────────────────────────────────────────────────────
def draw_uip_comparison(w=500, h=130):
d = Drawing(w, h)
d.add(Rect(0, 0, w, h, fillColor=LGREY, strokeColor=None))
labels = ['Typical UIP', 'Probable UIP', 'Indeterminate', 'Non-UIP/Alt Dx']
colors_ = [GREEN, TEAL, GOLD, RED]
x_positions = [w*0.12, w*0.37, w*0.62, w*0.87]
findings = [
['Subpleural+basal', 'Reticulation', 'Traction bronchiect.', 'HONEYCOMBING'],
['Subpleural+basal', 'Reticulation', 'Traction bronchiect.', 'No honeycombing'],
['Variable/diffuse', 'Some fibrosis', 'Inconclusive', 'features'],
['Upper/mid lung', 'Consolidation', 'Diffuse GGO', 'Diffuse cysts'],
]
for i, (lbl, col, xc, flist) in enumerate(zip(labels, colors_, x_positions, findings)):
# Header circle
d.add(Circle(xc, h*0.82, 14, fillColor=col, strokeColor=None))
d.add(String(xc, h*0.78, str(i+1), fontSize=10, fillColor=white,
textAnchor='middle', fontName='Helvetica-Bold'))
d.add(String(xc, h*0.64, lbl, fontSize=7.5, fillColor=col,
textAnchor='middle', fontName='Helvetica-Bold'))
for j, f in enumerate(flist):
bullet = u'\u2022 '
d.add(String(xc - 38, h*0.50 - j*13, bullet + f, fontSize=6.5,
fillColor=DARKGREY))
# Biopsy indicator
d.add(String(w*0.62, 12, 'Biopsy needed', fontSize=7, fillColor=RED,
fontName='Helvetica-Bold', textAnchor='middle'))
d.add(String(w*0.87, 12, 'Biopsy needed', fontSize=7, fillColor=RED,
fontName='Helvetica-Bold', textAnchor='middle'))
d.add(String(w*0.12, 12, 'No biopsy', fontSize=7, fillColor=GREEN,
fontName='Helvetica-Bold', textAnchor='middle'))
d.add(String(w*0.37, 12, 'Usually no biopsy', fontSize=7, fillColor=GREEN,
fontName='Helvetica-Bold', textAnchor='middle'))
return d
# ── Window Settings Diagram ────────────────────────────────────────────────────
def draw_window_comparison(w=400, h=100):
d = Drawing(w, h)
d.add(Rect(0, 0, w, h, fillColor=LGREY, strokeColor=None))
windows = [
('Lung Window', 'W:1500 / L:-700',
[CT_AIRSPACE, HexColor('#505050'), HexColor('#888888'), HexColor('#CCCCCC')],
['Airspace', 'GGO', 'Vessels', 'Consol.']),
('Mediastinal', 'W:400 / L:40',
[HexColor('#888888'), HexColor('#AAAAAA'), HexColor('#FFFFFF'), HexColor('#DDDDDD')],
['Tissue', 'Fat', 'Bone', 'Vessels']),
('Bone Window', 'W:2000 / L:400',
[HexColor('#000000'), HexColor('#666666'), HexColor('#BBBBBB'), HexColor('#FFFFFF')],
['Soft Tiss.', 'Cortex', 'Medulla', 'Dense']),
]
wx = [w*0.18, w*0.5, w*0.82]
for (wname, wset, wcolors, wlabels), xc in zip(windows, wx):
d.add(String(xc, h*0.93, wname, fontSize=8, fillColor=TEAL,
textAnchor='middle', fontName='Helvetica-Bold'))
d.add(String(xc, h*0.82, wset, fontSize=7, fillColor=MIDGREY,
textAnchor='middle'))
for k, (wc, wl) in enumerate(zip(wcolors, wlabels)):
bx = xc - 30 + k*16
d.add(Rect(bx, h*0.45, 14, 22, fillColor=wc,
strokeColor=HexColor('#999999'), strokeWidth=0.5))
d.add(String(bx+7, h*0.35, wl, fontSize=5.5, fillColor=DARKGREY,
textAnchor='middle'))
return d
# ── CECT Applications Diagram ──────────────────────────────────────────────────
def draw_cect_phases(w=450, h=80):
d = Drawing(w, h)
d.add(Rect(0, 0, w, h, fillColor=LGREY, strokeColor=None))
phases = [
('Non-contrast', '0 sec', DARKGREY, 'Calcification\nHemorrhage'),
('Arterial/CTPA', '15-20 sec', RED, 'Pulm. arteries\nAorta\nEmbolism'),
('Venous', '60-70 sec', TEAL, 'Mediastinum\nLymph nodes\nMasses'),
('Delayed', '3-5 min', PURPLE, 'Fibrous tumors\nInfection'),
]
pw = w / len(phases)
for i, (name, timing, col, desc) in enumerate(phases):
xc = (i + 0.5) * pw
# Arrow connector
if i < len(phases) - 1:
d.add(Line((i+1)*pw - 5, h*0.65, (i+1)*pw + 5, h*0.65,
strokeColor=MIDGREY, strokeWidth=1))
d.add(Rect(xc-30, h*0.5, 60, 22, fillColor=col, strokeColor=None,
rx=4, ry=4))
d.add(String(xc, h*0.56, name, fontSize=7, fillColor=white,
textAnchor='middle', fontName='Helvetica-Bold'))
d.add(String(xc, h*0.44, timing, fontSize=6.5, fillColor=col,
textAnchor='middle', fontName='Helvetica-Bold'))
for j, line in enumerate(desc.split('\n')):
d.add(String(xc, h*0.28 - j*10, line, fontSize=6, fillColor=DARKGREY,
textAnchor='middle'))
return d
# ── Distribution Map ───────────────────────────────────────────────────────────
def draw_distribution_map(w=460, h=150):
d = Drawing(w, h)
d.add(Rect(0, 0, w, h, fillColor=LGREY, strokeColor=None))
def lung_shape(d, xc, yc, wr, hr, fill, label_text):
# Simplified lung as ellipse
d.add(Ellipse(xc, yc, wr, hr, fillColor=fill,
strokeColor=HexColor('#555555'), strokeWidth=1))
d.add(String(xc, yc - 4, label_text, fontSize=6, fillColor=white,
textAnchor='middle', fontName='Helvetica-Bold'))
# Coronal view pair
cx = w*0.25
# Right lung
lung_shape(d, cx - 22, h*0.5, 18, 50, HexColor('#1B6CA8'), 'R')
# Left lung
lung_shape(d, cx + 22, h*0.5, 16, 48, HexColor('#1B6CA8'), 'L')
# Upper zone highlight (e.g. sarcoidosis)
d.add(Ellipse(cx - 22, h*0.75, 16, 15,
fillColor=HexColor('#F5A623'), strokeColor=None))
d.add(Ellipse(cx + 22, h*0.75, 14, 14,
fillColor=HexColor('#F5A623'), strokeColor=None))
d.add(String(cx, h*0.93, 'Upper Zone', fontSize=7, fillColor=GOLD,
textAnchor='middle', fontName='Helvetica-Bold'))
d.add(String(cx, h*0.85, 'Sarcoid, Silicosis,', fontSize=6.5, fillColor=DARKGREY,
textAnchor='middle'))
d.add(String(cx, h*0.77, 'LCH, Smoking-ILD', fontSize=6.5, fillColor=DARKGREY,
textAnchor='middle'))
d.add(String(cx, h*0.10, 'UPPER ZONE', fontSize=7.5, fillColor=GOLD,
textAnchor='middle', fontName='Helvetica-Bold'))
# Lower zone
cx2 = w*0.5
lung_shape(d, cx2 - 22, h*0.5, 18, 50, HexColor('#1B6CA8'), 'R')
lung_shape(d, cx2 + 22, h*0.5, 16, 48, HexColor('#1B6CA8'), 'L')
d.add(Ellipse(cx2 - 22, h*0.28, 16, 18,
fillColor=HexColor('#E74C3C'), strokeColor=None))
d.add(Ellipse(cx2 + 22, h*0.28, 14, 17,
fillColor=HexColor('#E74C3C'), strokeColor=None))
d.add(String(cx2, h*0.93, 'Lower Zone', fontSize=7, fillColor=RED,
textAnchor='middle', fontName='Helvetica-Bold'))
d.add(String(cx2, h*0.85, 'IPF/UIP, NSIP,', fontSize=6.5, fillColor=DARKGREY,
textAnchor='middle'))
d.add(String(cx2, h*0.77, 'Aspiration, Edema', fontSize=6.5, fillColor=DARKGREY,
textAnchor='middle'))
d.add(String(cx2, h*0.10, 'LOWER ZONE', fontSize=7.5, fillColor=RED,
textAnchor='middle', fontName='Helvetica-Bold'))
# Peripheral / Central
cx3 = w*0.76
lung_shape(d, cx3 - 22, h*0.5, 18, 50, HexColor('#1B6CA8'), 'R')
lung_shape(d, cx3 + 22, h*0.5, 16, 48, HexColor('#1B6CA8'), 'L')
# Peripheral ring
d.add(Ellipse(cx3 - 22, h*0.5, 18, 50,
fillColor=None, strokeColor=GREEN, strokeWidth=3))
d.add(Ellipse(cx3 + 22, h*0.5, 16, 48,
fillColor=None, strokeColor=GREEN, strokeWidth=3))
d.add(String(cx3, h*0.93, 'Peripheral', fontSize=7, fillColor=GREEN,
textAnchor='middle', fontName='Helvetica-Bold'))
d.add(String(cx3, h*0.85, 'UIP, COP,', fontSize=6.5, fillColor=DARKGREY,
textAnchor='middle'))
d.add(String(cx3, h*0.77, 'Eosinophilic PNA', fontSize=6.5, fillColor=DARKGREY,
textAnchor='middle'))
d.add(String(cx3, h*0.10, 'PERIPH/CENTRAL', fontSize=7.5, fillColor=GREEN,
textAnchor='middle', fontName='Helvetica-Bold'))
return d
# ── Page header/footer callbacks ────────────────────────────────────────────────
def page_header_footer(canvas, doc):
canvas.saveState()
# Header bar
canvas.setFillColor(NAVY)
canvas.rect(0, H - 28, W, 28, fill=1, stroke=0)
canvas.setFont('Helvetica-Bold', 9)
canvas.setFillColor(white)
canvas.drawString(1.5*cm, H - 18, 'HRCT & CECT CHEST | Visual Reference Guide')
canvas.setFont('Helvetica', 8)
canvas.setFillColor(CYAN)
canvas.drawRightString(W - 1.5*cm, H - 18, 'Orris Medical 2026')
# Footer
canvas.setFillColor(LGREY)
canvas.rect(0, 0, W, 18, fill=1, stroke=0)
canvas.setFont('Helvetica', 7)
canvas.setFillColor(MIDGREY)
canvas.drawCentredString(W/2, 5, f'Page {doc.page} | For educational use only')
canvas.restoreState()
def cover_page(canvas, doc):
canvas.saveState()
# Full gradient-like background
canvas.setFillColor(NAVY)
canvas.rect(0, 0, W, H, fill=1, stroke=0)
# Accent band top
canvas.setFillColor(TEAL)
canvas.rect(0, H - 8, W, 8, fill=1, stroke=0)
# Bottom accent
canvas.setFillColor(TEAL)
canvas.rect(0, 0, W, 8, fill=1, stroke=0)
# Decorative circles
canvas.setFillColor(HexColor('#1A3A5C'))
canvas.circle(W*0.85, H*0.75, 120, fill=1, stroke=0)
canvas.setFillColor(HexColor('#153250'))
canvas.circle(W*0.1, H*0.2, 80, fill=1, stroke=0)
canvas.restoreState()
# ── Build PDF ──────────────────────────────────────────────────────────────────
def build_pdf(path):
doc = SimpleDocTemplate(
path,
pagesize=A4,
leftMargin=1.5*cm,
rightMargin=1.5*cm,
topMargin=1.8*cm,
bottomMargin=1.2*cm,
)
story = []
# ── COVER PAGE ───────────────────────────────────────────────────────────
story.append(Spacer(1, 4*cm))
story.append(Paragraph('HRCT & CECT CHEST', ST['cover_title']))
story.append(Paragraph('Visual Reference Guide', ST['cover_sub']))
story.append(Spacer(1, 0.4*cm))
story.append(Paragraph('Patterns · Pathology · Systematic Approach', ST['cover_tag']))
story.append(Spacer(1, 0.6*cm))
story.append(Paragraph('Fleischner Society Classification · CT Windows · CECT Phases',
ST['cover_tag']))
story.append(Spacer(1, 2*cm))
# Mini contents list
toc_data = [
[Paragraph('Section', ST['table_h']), Paragraph('Page', ST['table_h'])],
[Paragraph('1. Technical Essentials & CT Windows', ST['table_cell']), Paragraph('2', ST['table_cell'])],
[Paragraph('2. Secondary Pulmonary Lobule Anatomy', ST['table_cell']), Paragraph('3', ST['table_cell'])],
[Paragraph('3. HRCT Patterns Gallery', ST['table_cell']), Paragraph('4', ST['table_cell'])],
[Paragraph('4. Distribution & Zonal Patterns', ST['table_cell']), Paragraph('5', ST['table_cell'])],
[Paragraph('5. Nodule Distribution: Centrilobular vs Perilymphatic vs Random', ST['table_cell']), Paragraph('6', ST['table_cell'])],
[Paragraph('6. Fleischner UIP Classification', ST['table_cell']), Paragraph('7', ST['table_cell'])],
[Paragraph('7. CECT Chest: Phases & Applications', ST['table_cell']), Paragraph('8', ST['table_cell'])],
[Paragraph('8. Systematic Reading Checklist', ST['table_cell']), Paragraph('9', ST['table_cell'])],
[Paragraph('9. Quick Pattern-Disease Lookup Table', ST['table_cell']), Paragraph('10', ST['table_cell'])],
]
toc_style = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), TEAL),
('TEXTCOLOR', (0, 0), (-1, 0), white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#D6EAF8'), HexColor('#EBF5FB')]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#AED6F1')),
('FONTSIZE', (0, 1), (-1, -1), 9),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
])
toc = Table(toc_data, colWidths=[12*cm, 2*cm])
toc.setStyle(toc_style)
story.append(toc)
story.append(Spacer(1, 1.5*cm))
story.append(Paragraph('Based on Murray & Nadel\'s Respiratory Medicine, Fishman\'s Pulmonary Diseases, '
'Fleischner Society 2018 UIP Criteria, and Radiological Society guidelines.',
ST['cover_tag']))
story.append(PageBreak())
# ── PAGE 2 – Technical Essentials ────────────────────────────────────────
story.append(ColorBand(' SECTION 1 – TECHNICAL ESSENTIALS & CT WINDOWS', bg=NAVY, height=30))
story.append(Spacer(1, 6))
# Window settings
story.append(Paragraph('CT Window Settings', ST['sub_h']))
win_data = [
[Paragraph('Window', ST['table_h']),
Paragraph('Width (W)', ST['table_h']),
Paragraph('Level (L)', ST['table_h']),
Paragraph('Best For', ST['table_h'])],
[Paragraph('Lung', ST['table_cell']),
Paragraph('1500 HU', ST['table_cell']),
Paragraph('-700 HU', ST['table_cell']),
Paragraph('Parenchyma, airways, emphysema, ILD', ST['table_cell'])],
[Paragraph('Mediastinal / Soft Tissue', ST['table_cell']),
Paragraph('350-400 HU', ST['table_cell']),
Paragraph('40 HU', ST['table_cell']),
Paragraph('Lymph nodes, masses, vessels, pleura', ST['table_cell'])],
[Paragraph('Bone', ST['table_cell']),
Paragraph('2000 HU', ST['table_cell']),
Paragraph('400 HU', ST['table_cell']),
Paragraph('Ribs, vertebrae, sternum, cortex', ST['table_cell'])],
[Paragraph('High-Res Lung (HRCT)', ST['table_cell']),
Paragraph('1500-2000 HU', ST['table_cell']),
Paragraph('-650 HU', ST['table_cell']),
Paragraph('Fine interstitial detail, GGO, fibrosis', ST['table_cell'])],
]
win_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), TEAL),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LGREEN]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
])
win_t = Table(win_data, colWidths=[4.5*cm, 2.8*cm, 2.5*cm, 6.2*cm])
win_t.setStyle(win_ts)
story.append(win_t)
story.append(Spacer(1, 6))
story.append(Paragraph('Window Grayscale Visual', ST['sub_h']))
story.append(draw_window_comparison(460, 90))
story.append(Paragraph('Each window reveals different structures. Always view chest CT in at least lung AND mediastinal windows.',
ST['caption']))
story.append(Spacer(1, 6))
# HRCT vs Standard CT
story.append(Paragraph('HRCT vs. Standard (Non-contrast) CT Chest', ST['sub_h']))
diff_data = [
[Paragraph('Feature', ST['table_h']),
Paragraph('Standard CT', ST['table_h']),
Paragraph('HRCT', ST['table_h'])],
[Paragraph('Slice thickness', ST['table_cell']),
Paragraph('3-5 mm', ST['table_cell']),
Paragraph('\u22641.5 mm (ideally 0.625-1 mm)', ST['table_cell'])],
[Paragraph('Algorithm', ST['table_cell']),
Paragraph('Standard / smooth', ST['table_cell']),
Paragraph('High spatial-frequency (sharp)', ST['table_cell'])],
[Paragraph('Contrast used?', ST['table_cell']),
Paragraph('Optional', ST['table_cell']),
Paragraph('NO (not needed for ILD)', ST['table_cell'])],
[Paragraph('Best for', ST['table_cell']),
Paragraph('Mediastinum, staging, PE', ST['table_cell']),
Paragraph('ILD, emphysema, bronchiectasis, fine parenchyma', ST['table_cell'])],
[Paragraph('Extra sequences', ST['table_cell']),
Paragraph('None routine', ST['table_cell']),
Paragraph('Prone (atelectasis vs ILD), Expiratory (air trapping)', ST['table_cell'])],
]
diff_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), NAVY),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LBLUE]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
])
diff_t = Table(diff_data, colWidths=[4.5*cm, 4.5*cm, 7*cm])
diff_t.setStyle(diff_ts)
story.append(diff_t)
story.append(Spacer(1, 8))
story.append(TipBox(
'Tip: Prone images distinguish true lower-lobe ILD from dependent atelectasis. '
'Expiratory scans unmask air trapping (lobular hyperlucency) in small airway disease.',
bg=LGREEN, border=GREEN, label='CLINICAL PEARL'))
story.append(PageBreak())
# ── PAGE 3 – Secondary Lobule ─────────────────────────────────────────────
story.append(ColorBand(' SECTION 2 – SECONDARY PULMONARY LOBULE ANATOMY', bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'The secondary pulmonary lobule (SPL) is the basic anatomical unit of HRCT interpretation. '
'It is a polyhedral unit (~1-2.5 cm diameter) bounded by interlobular septa containing pulmonary veins and lymphatics. '
'The centrilobular region in the center contains the pulmonary arteriole and bronchiole. '
'Disease localization within the SPL forms the basis of HRCT differential diagnosis.',
ST['body']))
story.append(Spacer(1, 6))
story.append(draw_secondary_lobule(480, 220))
story.append(Spacer(1, 6))
# Distribution within SPL
story.append(Paragraph('Disease Distribution Within the Secondary Lobule', ST['sub_h']))
spl_data = [
[Paragraph('Distribution', ST['table_h']),
Paragraph('Anatomic Site', ST['table_h']),
Paragraph('HRCT Appearance', ST['table_h']),
Paragraph('Classic Diseases', ST['table_h'])],
[Paragraph('Centrilobular', ST['table_cell']),
Paragraph('Center of lobule\n(artery + bronchiole)', ST['table_cell']),
Paragraph('Nodules/GGO sparing\nthe pleural surface\n(2-3 mm from pleura)', ST['table_cell']),
Paragraph('Hypersensitivity pneumonitis,\nRespiratory bronchiolitis,\nEndobronchial TB/infection,\nSubacute HP', ST['table_cell'])],
[Paragraph('Perilymphatic', ST['table_cell']),
Paragraph('Along septa, fissures,\nbronchovascular bundles', ST['table_cell']),
Paragraph('Nodules at pleura,\nfissures and along\nbronchi', ST['table_cell']),
Paragraph('Sarcoidosis (classic),\nLymphangitic carcinomatosis,\nSilicosis, Coal workers PD', ST['table_cell'])],
[Paragraph('Random', ST['table_cell']),
Paragraph('No lobular predilection\n(hematogenous spread)', ST['table_cell']),
Paragraph('Uniform distribution,\nno zonal preference,\npleura involved', ST['table_cell']),
Paragraph('Miliary TB, Fungal\n(histoplasma), Haematogenous\nmetastases, Varicella PNA', ST['table_cell'])],
]
spl_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), PURPLE),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LPURPLE]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
spl_t = Table(spl_data, colWidths=[3*cm, 3.5*cm, 4*cm, 5.5*cm])
spl_t.setStyle(spl_ts)
story.append(spl_t)
story.append(PageBreak())
# ── PAGE 4 – HRCT Patterns Gallery ───────────────────────────────────────
story.append(ColorBand(' SECTION 3 – HRCT PATTERNS GALLERY', bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'Four dominant HRCT patterns are recognized. Each circle below simulates an axial CT view of the lung '
'(black = normal air density). Identify the dominant pattern first, then refine by distribution and additional features.',
ST['body']))
story.append(Spacer(1, 8))
# Row 1: Normal, GGO, Consolidation, Reticulation
pw = 120
ph = 100
patterns_row1 = [
(pattern_normal(pw, ph), 'NORMAL', 'Fine branching vessels on dark background', HexColor('#D6EAF8')),
(pattern_ggo(pw, ph), 'GROUND GLASS (GGO)', 'Hazy opacity; vessels still visible through it', HexColor('#EAF7EA')),
(pattern_consolidation(pw, ph), 'CONSOLIDATION', 'Dense opacity obscuring vessels; air bronchogram', HexColor('#FDEDEC')),
(pattern_reticulation(pw, ph), 'RETICULATION', 'Intersecting lines; subpleural accentuation', HexColor('#FEF9E7')),
]
patterns_row2 = [
(pattern_honeycombing(pw, ph), 'HONEYCOMBING', 'Clustered subpleural cysts 3-10 mm; fibrosis', LRED),
(pattern_emphysema(pw, ph), 'EMPHYSEMA', 'Lucent areas WITHOUT walls; centrilobular artery dot', LGREY),
(pattern_bronchiectasis(pw, ph), 'BRONCHIECTASIS', 'Signet ring sign: dilated bronchus > adjacent artery', HexColor('#EAF7EA')),
(pattern_crazy_paving(pw, ph), 'CRAZY PAVING', 'GGO + superimposed septal lines; classic: alveolar proteinosis', LPURPLE),
]
for row_patterns in [patterns_row1, patterns_row2]:
row_cells = []
for drw, title, desc, bg in row_patterns:
cell_content = [
Spacer(1, 3),
drw,
Spacer(1, 3),
Paragraph(f'<b>{title}</b>', ParagraphStyle('pc', fontName='Helvetica-Bold',
fontSize=8, textColor=NAVY, alignment=TA_CENTER)),
Paragraph(desc, ParagraphStyle('pd', fontName='Helvetica', fontSize=7,
textColor=DARKGREY, alignment=TA_CENTER, leading=10)),
Spacer(1, 4),
]
row_cells.append(cell_content)
pat_t = Table([row_cells],
colWidths=[3.8*cm, 3.8*cm, 3.8*cm, 3.8*cm])
pat_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), patterns_row1[0][3] if row_patterns == patterns_row1 else patterns_row2[0][3]),
('BACKGROUND', (1, 0), (1, 0), patterns_row1[1][3] if row_patterns == patterns_row1 else patterns_row2[1][3]),
('BACKGROUND', (2, 0), (2, 0), patterns_row1[2][3] if row_patterns == patterns_row1 else patterns_row2[2][3]),
('BACKGROUND', (3, 0), (3, 0), patterns_row1[3][3] if row_patterns == patterns_row1 else patterns_row2[3][3]),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOX', (0, 0), (-1, -1), 0.5, HexColor('#AEB6BF')),
('INNERGRID', (0, 0), (-1, -1), 0.5, HexColor('#AEB6BF')),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]))
story.append(pat_t)
story.append(Spacer(1, 6))
# Additional pattern: Mosaic & Mass
story.append(Paragraph('Additional Important Patterns', ST['sub_h']))
extra_row = [
(pattern_mosaic(pw, ph), 'MOSAIC ATTENUATION', 'Lobular patchwork; darker lobules = air trapping', LBLUE),
(pattern_mass(pw, ph), 'SPICULATED MASS', 'Radiating spicules = malignancy until proven otherwise', LRED),
]
extra_cells = []
for drw, title, desc, bg in extra_row:
extra_cells.append([
Spacer(1, 3), drw, Spacer(1, 3),
Paragraph(f'<b>{title}</b>', ParagraphStyle('pc2', fontName='Helvetica-Bold',
fontSize=8, textColor=NAVY, alignment=TA_CENTER)),
Paragraph(desc, ParagraphStyle('pd2', fontName='Helvetica', fontSize=7,
textColor=DARKGREY, alignment=TA_CENTER, leading=10)),
Spacer(1, 4),
])
extra_t = Table([extra_cells + [[''] * 6, [''] * 6]],
colWidths=[3.8*cm, 3.8*cm, 3.8*cm, 3.8*cm])
extra_t2 = Table([extra_cells],
colWidths=[7.6*cm, 7.6*cm])
extra_t2.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), LBLUE),
('BACKGROUND', (1, 0), (1, 0), LRED),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOX', (0, 0), (-1, -1), 0.5, HexColor('#AEB6BF')),
('INNERGRID', (0, 0), (-1, -1), 0.5, HexColor('#AEB6BF')),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]))
story.append(extra_t2)
story.append(PageBreak())
# ── PAGE 5 – Distribution ────────────────────────────────────────────────
story.append(ColorBand(' SECTION 4 – DISTRIBUTION & ZONAL PATTERNS', bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'After identifying the dominant pattern, determine WHERE it is located. '
'Zonal and central-peripheral distribution narrows the differential significantly.',
ST['body']))
story.append(Spacer(1, 8))
story.append(draw_distribution_map(480, 155))
story.append(Spacer(1, 8))
dist_data = [
[Paragraph('Distribution', ST['table_h']),
Paragraph('Diseases to Consider', ST['table_h']),
Paragraph('Key Differentiating Feature', ST['table_h'])],
[Paragraph('Upper zone\n(upper lobes)', ST['table_cell']),
Paragraph('Sarcoidosis, Silicosis, Coal workers PD, LCH,\nHypersensitivity pneumonitis (some), Old TB', ST['table_cell']),
Paragraph('Sarcoid: perilymphatic nodules + hilar nodes\nLCH: bizarre cysts + history of smoking', ST['table_cell'])],
[Paragraph('Lower zone\n(basal)', ST['table_cell']),
Paragraph('UIP/IPF, NSIP, Aspiration pneumonia,\nPulmonary edema, BOOP/COP', ST['table_cell']),
Paragraph('UIP: subpleural honeycombing\nNSIP: diffuse GGO, less honeycombing\nEdema: septal lines, effusion', ST['table_cell'])],
[Paragraph('Perihilar / Central', ST['table_cell']),
Paragraph('Sarcoidosis, Lymphangitic carcinomatosis,\nBronchitis, Cardiogenic pulmonary edema', ST['table_cell']),
Paragraph('Sarcoid: peribronchial thickening\nLymphangitic: irregular septal lines + nodes', ST['table_cell'])],
[Paragraph('Peripheral / Subpleural', ST['table_cell']),
Paragraph('UIP/IPF (classic), COP,\nChronic eosinophilic pneumonia, NSIP', ST['table_cell']),
Paragraph('COP: peripheral consolidation, "reverse halo"\nIPF: honeycombing + traction bronchiectasis', ST['table_cell'])],
[Paragraph('Diffuse / Random', ST['table_cell']),
Paragraph('Pulmonary edema (bilateral), Miliary TB,\nHematogenous metastases, NSIP', ST['table_cell']),
Paragraph('Miliary: uniform tiny nodules in all zones\nEdema: basal GGO + bilateral effusions', ST['table_cell'])],
]
dist_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), TEAL),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LBLUE]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
dist_t = Table(dist_data, colWidths=[3*cm, 6*cm, 7*cm])
dist_t.setStyle(dist_ts)
story.append(dist_t)
story.append(PageBreak())
# ── PAGE 6 – Nodule Types ────────────────────────────────────────────────
story.append(ColorBand(' SECTION 5 – NODULE DISTRIBUTION: CENTRILOBULAR vs PERILYMPHATIC vs RANDOM',
bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'Nodule distribution within the secondary pulmonary lobule is one of the most powerful differentiators in HRCT. '
'The three patterns have fundamentally different pathological mechanisms and disease associations.',
ST['body']))
story.append(Spacer(1, 8))
# Three nodule pattern circles side by side
nod_cells = [
[Spacer(1, 3),
pattern_nodules(120, 100, 'centrilobular'),
Spacer(1, 3),
Paragraph('<b>CENTRILOBULAR</b>', ParagraphStyle('nc', fontName='Helvetica-Bold',
fontSize=9, textColor=TEAL, alignment=TA_CENTER)),
Paragraph('Spares the pleura (2-3 mm gap)\nCentrilobular artery at center', ParagraphStyle('nd',
fontName='Helvetica', fontSize=7.5, textColor=DARKGREY, alignment=TA_CENTER, leading=11)),
Spacer(1, 4)],
[Spacer(1, 3),
pattern_nodules(120, 100, 'perilymphatic'),
Spacer(1, 3),
Paragraph('<b>PERILYMPHATIC</b>', ParagraphStyle('nc2', fontName='Helvetica-Bold',
fontSize=9, textColor=PURPLE, alignment=TA_CENTER)),
Paragraph('Along pleura, fissures &\nbronchovascular bundles', ParagraphStyle('nd2',
fontName='Helvetica', fontSize=7.5, textColor=DARKGREY, alignment=TA_CENTER, leading=11)),
Spacer(1, 4)],
[Spacer(1, 3),
pattern_nodules(120, 100, 'random'),
Spacer(1, 3),
Paragraph('<b>RANDOM</b>', ParagraphStyle('nc3', fontName='Helvetica-Bold',
fontSize=9, textColor=RED, alignment=TA_CENTER)),
Paragraph('No lobular preference\nPleural surface also involved', ParagraphStyle('nd3',
fontName='Helvetica', fontSize=7.5, textColor=DARKGREY, alignment=TA_CENTER, leading=11)),
Spacer(1, 4)],
]
nod_t = Table([nod_cells], colWidths=[5.3*cm, 5.3*cm, 5.3*cm])
nod_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), LBLUE),
('BACKGROUND', (1, 0), (1, 0), LPURPLE),
('BACKGROUND', (2, 0), (2, 0), LRED),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOX', (0, 0), (-1, -1), 1, TEAL),
('INNERGRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
story.append(nod_t)
story.append(Spacer(1, 8))
nod2_data = [
[Paragraph('Feature', ST['table_h']),
Paragraph('Centrilobular', ST['table_h']),
Paragraph('Perilymphatic', ST['table_h']),
Paragraph('Random', ST['table_h'])],
[Paragraph('Pleura spared?', ST['table_cell']),
Paragraph('YES (classic)', ST['table_cell']),
Paragraph('NO (nodules at pleura)', ST['table_cell']),
Paragraph('NO (random)', ST['table_cell'])],
[Paragraph('Fissure nodules?', ST['table_cell']),
Paragraph('No', ST['table_cell']),
Paragraph('YES (classic)', ST['table_cell']),
Paragraph('Sometimes', ST['table_cell'])],
[Paragraph('Zonal predominance', ST['table_cell']),
Paragraph('Upper/mid (HP)\nUpper (smoking-RB)', ST['table_cell']),
Paragraph('Upper (sarcoid)\nBases (lymphangitic)', ST['table_cell']),
Paragraph('None (uniform)', ST['table_cell'])],
[Paragraph('Key diseases', ST['table_cell']),
Paragraph('Hypersensitivity pneumonitis\nRespiratory bronchiolitis\nEndobronchial TB spread\nSubacute HP', ST['table_cell']),
Paragraph('Sarcoidosis (1-2-3 sign)\nLymphangitic carcinomatosis\nSilicosis / Coal workers PD', ST['table_cell']),
Paragraph('Miliary TB / Fungal\nHematogenous metastases\nVaricella pneumonia', ST['table_cell'])],
[Paragraph('Associated features', ST['table_cell']),
Paragraph('"Tree-in-bud" = endobronchial\ninfection', ST['table_cell']),
Paragraph('Bilateral hilar nodes (sarcoid)\nKnown malignancy (lymphangitic)', ST['table_cell']),
Paragraph('Very uniform, tiny (<3 mm)\nNo lymphadenopathy', ST['table_cell'])],
]
nod2_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), PURPLE),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LPURPLE]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
nod2_t = Table(nod2_data, colWidths=[3.5*cm, 4.5*cm, 4.5*cm, 4.5*cm])
nod2_t.setStyle(nod2_ts)
story.append(nod2_t)
story.append(PageBreak())
# ── PAGE 7 – UIP / Fleischner ────────────────────────────────────────────
story.append(ColorBand(' SECTION 6 – FLEISCHNER SOCIETY UIP/IPF CLASSIFICATION', bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'The Fleischner Society 2018 classification divides HRCT findings for suspected UIP/IPF into four categories '
'with direct implications for biopsy decisions and management.',
ST['body']))
story.append(Spacer(1, 8))
story.append(draw_uip_comparison(480, 135))
story.append(Spacer(1, 8))
uip_data = [
[Paragraph('Pattern', ST['table_h']),
Paragraph('Distribution', ST['table_h']),
Paragraph('CT Features', ST['table_h']),
Paragraph('Biopsy?', ST['table_h']),
Paragraph('PPV for UIP histology', ST['table_h'])],
[Paragraph('Typical UIP', ParagraphStyle('u1', fontName='Helvetica-Bold',
fontSize=8, textColor=GREEN)),
Paragraph('Subpleural + basal\npredominant', ST['table_cell']),
Paragraph('Reticulation + traction bronchiectasis\n+ HONEYCOMBING\n(no alternative diagnosis features)', ST['table_cell']),
Paragraph('NO', ParagraphStyle('nb', fontName='Helvetica-Bold',
fontSize=9, textColor=GREEN)),
Paragraph('>90%', ST['table_cell'])],
[Paragraph('Probable UIP', ParagraphStyle('u2', fontName='Helvetica-Bold',
fontSize=8, textColor=TEAL)),
Paragraph('Subpleural + basal\npredominant', ST['table_cell']),
Paragraph('Reticulation + traction bronchiectasis\nNO honeycombing', ST['table_cell']),
Paragraph('Usually NO', ParagraphStyle('unb', fontName='Helvetica-Bold',
fontSize=9, textColor=TEAL)),
Paragraph('>70%', ST['table_cell'])],
[Paragraph('Indeterminate', ParagraphStyle('u3', fontName='Helvetica-Bold',
fontSize=8, textColor=GOLD)),
Paragraph('Variable/diffuse\nor inconclusive', ST['table_cell']),
Paragraph('Fibrotic features but inconclusive;\nsome non-UIP features present', ST['table_cell']),
Paragraph('YES', ParagraphStyle('yb', fontName='Helvetica-Bold',
fontSize=9, textColor=GOLD)),
Paragraph('~50%', ST['table_cell'])],
[Paragraph('Non-IPF / Alt Dx', ParagraphStyle('u4', fontName='Helvetica-Bold',
fontSize=8, textColor=RED)),
Paragraph('Upper/mid lung OR\ncentral/peribronchovasc.', ST['table_cell']),
Paragraph('Consolidation, diffuse pure GGO,\nextensive mosaic, diffuse cysts/nodules', ST['table_cell']),
Paragraph('YES\n(different Dx)', ParagraphStyle('yb2', fontName='Helvetica-Bold',
fontSize=9, textColor=RED)),
Paragraph('Low', ST['table_cell'])],
]
uip_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), NAVY),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [LGREEN, HexColor('#EBF5FB'), LGOLD, LRED]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
uip_t = Table(uip_data, colWidths=[2.8*cm, 3*cm, 5.5*cm, 2.2*cm, 2.5*cm])
uip_t.setStyle(uip_ts)
story.append(uip_t)
story.append(Spacer(1, 8))
# Additional ILD patterns
story.append(Paragraph('Common ILD Patterns on HRCT', ST['sub_h']))
ild_data = [
[Paragraph('ILD Type', ST['table_h']),
Paragraph('Predominant Zone', ST['table_h']),
Paragraph('Key HRCT Features', ST['table_h']),
Paragraph('Distinguishing Feature', ST['table_h'])],
[Paragraph('UIP (IPF)', ST['table_cell']),
Paragraph('Lower + subpleural', ST['table_cell']),
Paragraph('Reticulation, honeycombing, traction bronchiectasis', ST['table_cell']),
Paragraph('Honeycombing is hallmark', ST['table_cell'])],
[Paragraph('NSIP', ST['table_cell']),
Paragraph('Lower + diffuse', ST['table_cell']),
Paragraph('Diffuse GGO, reticulation, NO honeycombing (or rare)', ST['table_cell']),
Paragraph('Subpleural sparing in 50%', ST['table_cell'])],
[Paragraph('COP (Organizing PNA)', ST['table_cell']),
Paragraph('Peripheral, lower', ST['table_cell']),
Paragraph('Peripheral consolidation, "reverse halo sign"', ST['table_cell']),
Paragraph('Migrates over time', ST['table_cell'])],
[Paragraph('HP (Hypersensitivity P.)', ST['table_cell']),
Paragraph('Upper/mid, centrilobular', ST['table_cell']),
Paragraph('Centrilobular nodules, GGO, air trapping', ST['table_cell']),
Paragraph('Spares bases; exposure history', ST['table_cell'])],
[Paragraph('DIP', ST['table_cell']),
Paragraph('Lower, diffuse', ST['table_cell']),
Paragraph('Diffuse GGO (macrophage filling)', ST['table_cell']),
Paragraph('Heavy smoker, under 40', ST['table_cell'])],
[Paragraph('Sarcoidosis', ST['table_cell']),
Paragraph('Upper + perilymphatic', ST['table_cell']),
Paragraph('Perilymphatic nodules, bilateral hilar adenopathy', ST['table_cell']),
Paragraph('"1-2-3 sign"; perihilar', ST['table_cell'])],
]
ild_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), TEAL),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LBLUE]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 7.5),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
ild_t = Table(ild_data, colWidths=[3*cm, 3*cm, 5.5*cm, 4.5*cm])
ild_t.setStyle(ild_ts)
story.append(ild_t)
story.append(PageBreak())
# ── PAGE 8 – CECT Chest ──────────────────────────────────────────────────
story.append(ColorBand(' SECTION 7 – CECT CHEST: PHASES & APPLICATIONS', bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'Contrast-enhanced CT (CECT) uses IV iodinated contrast to evaluate vessels, mediastinum, lymph nodes, '
'and enhancement patterns of masses. Contrast is NOT used for standard ILD/HRCT evaluation.',
ST['body']))
story.append(Spacer(1, 8))
story.append(Paragraph('CT Contrast Phases', ST['sub_h']))
story.append(draw_cect_phases(480, 85))
story.append(Spacer(1, 8))
phase_data = [
[Paragraph('Phase', ST['table_h']),
Paragraph('Timing', ST['table_h']),
Paragraph('Indication / Use', ST['table_h']),
Paragraph('Key Findings', ST['table_h'])],
[Paragraph('Non-contrast', ST['table_cell']),
Paragraph('Before injection', ST['table_cell']),
Paragraph('Calcification, hemorrhage, baseline HU', ST['table_cell']),
Paragraph('Dense = calcium/hemorrhage\nFat = lipoid lesion', ST['table_cell'])],
[Paragraph('Arterial / CTPA', ST['table_cell']),
Paragraph('15-20 seconds', ST['table_cell']),
Paragraph('Pulmonary embolism (CTPA), aortic dissection, AVM', ST['table_cell']),
Paragraph('Filling defect in pulmonary artery = PE\nIntimal flap = dissection', ST['table_cell'])],
[Paragraph('Venous / Portal', ST['table_cell']),
Paragraph('60-70 seconds', ST['table_cell']),
Paragraph('Mediastinal masses, lymph nodes, lung cancer staging', ST['table_cell']),
Paragraph('Enhancing nodes, mass characterization\nPleural enhancement', ST['table_cell'])],
[Paragraph('Delayed', ST['table_cell']),
Paragraph('3-5 minutes', ST['table_cell']),
Paragraph('Fibrous tumors, infection, vascular anomalies', ST['table_cell']),
Paragraph('Progressive enhancement = carcinoid\nWash-out = HCC', ST['table_cell'])],
]
phase_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), DARKGREY),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LBLUE]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
phase_t = Table(phase_data, colWidths=[2.8*cm, 2.5*cm, 5.5*cm, 5.2*cm])
phase_t.setStyle(phase_ts)
story.append(phase_t)
story.append(Spacer(1, 8))
# Mediastinal compartments
story.append(Paragraph('Mediastinal Compartments & Masses (ITMIG Classification)', ST['sub_h']))
med_data = [
[Paragraph('Compartment', ST['table_h']),
Paragraph('Contents', ST['table_h']),
Paragraph('Masses (4 T\'s)', ST['table_h']),
Paragraph('CT Features', ST['table_h'])],
[Paragraph('Prevascular\n(Anterior)', ParagraphStyle('ac', fontName='Helvetica-Bold',
fontSize=8, textColor=RED)),
Paragraph('Thymus, lymph nodes\nthyroid extension', ST['table_cell']),
Paragraph('Thymoma, Teratoma,\nTerrible (lymphoma),\nThyroid mass', ST['table_cell']),
Paragraph('Thymoma: heterogeneous, may invade pleura\nLymphoma: infiltrates vessels', ST['table_cell'])],
[Paragraph('Visceral\n(Middle)', ParagraphStyle('vc', fontName='Helvetica-Bold',
fontSize=8, textColor=TEAL)),
Paragraph('Trachea, heart, great vessels,\nesophagus, lymph nodes', ST['table_cell']),
Paragraph('Lymphoma, mediastinal\ncysts, vascular tumors', ST['table_cell']),
Paragraph('Pericardial cyst: homogeneous, no enhancement\nLymph node >1 cm SAX = abnormal', ST['table_cell'])],
[Paragraph('Paravertebral\n(Posterior)', ParagraphStyle('pc3', fontName='Helvetica-Bold',
fontSize=8, textColor=PURPLE)),
Paragraph('Sympathetic chain,\nneural structures\nalong spine', ST['table_cell']),
Paragraph('Neurogenic tumors\n(Schwannoma, NF,\nNeuroblastoma)', ST['table_cell']),
Paragraph('Dumbbell sign: foraminal extension\nSmooth margins, well-defined', ST['table_cell'])],
]
med_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), NAVY),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [LRED, HexColor('#EBF5FB'), LPURPLE]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
med_t = Table(med_data, colWidths=[2.8*cm, 3.5*cm, 4*cm, 5.7*cm])
med_t.setStyle(med_ts)
story.append(med_t)
story.append(PageBreak())
# ── PAGE 9 – Systematic Checklist ────────────────────────────────────────
story.append(ColorBand(' SECTION 8 – SYSTEMATIC READING CHECKLIST', bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'Use a fixed order every time. A systematic approach prevents errors of omission. '
'Read each CT in BOTH lung AND mediastinal windows before reporting.',
ST['body']))
story.append(Spacer(1, 8))
checklist = [
('1', 'TRACHEA & MAIN BRONCHI', TEAL,
['Position: central? Deviated?',
'Calibre: normal / narrowed / widened?',
'Wall thickening, intraluminal lesion?',
'Tracheal shape: saber-sheath (COPD)',
'Carina angle (normal <70 degrees)']),
('2', 'LUNG PARENCHYMA', GREEN,
['Use LUNG WINDOW (W:1500, L:-700)',
'Identify dominant pattern: GGO / Consolidation / Reticulation / Nodules / Low attenuation',
'Determine distribution: upper vs lower, central vs peripheral',
'Note: honeycombing, traction bronchiectasis, air trapping (expiratory scan)',
'Nodule: size, density, margins, distribution (centrilobular/perilymphatic/random)']),
('3', 'LYMPH NODES', PURPLE,
['Hilar nodes: normal <1 cm short axis',
'Bilateral hilar -> sarcoidosis, lymphoma',
'Unilateral hilar -> carcinoma, metastasis',
'Calcified nodes -> old granulomatous disease (TB, histoplasma)',
'Mediastinal nodes (short axis >1 cm = abnormal)']),
('4', 'PLEURA', ORANGE,
['Effusion: size, laterality, HU (transudate < 20, exudate > 35)',
'Pleural thickening: smooth vs nodular (nodular = malignant)',
'Pleural plaques (asbestos exposure)',
'Pneumothorax: apical and lateral recesses',
'Empyema: loculated, thickened enhancing pleura (split pleura sign)']),
('5', 'MEDIASTINUM & HEART', NAVY,
['Use MEDIASTINAL WINDOW (W:400, L:40)',
'Compartment of any mass: anterior / visceral / posterior',
'Pericardial effusion / thickening',
'Heart size, chamber enlargement',
'Aorta: diameter (aneurysm >4 cm), intimal flap (dissection)']),
('6', 'VESSELS (CECT only)', RED,
['CTPA: look for filling defects in pulmonary arteries (PE)',
'Saddle embolus at bifurcation of main pulmonary artery',
'RV:LV ratio >0.9 = right heart strain from PE',
'Aortic arch and great vessels: anomalies, dilatation',
'Pulmonary artery pressure signs: PA >3 cm = pulmonary hypertension']),
('7', 'BONES', MIDGREY,
['Use BONE WINDOW (W:2000, L:400)',
'Rib fractures: count each rib (acute vs healing)',
'Lytic lesion: myeloma, metastasis, infection',
'Sclerotic lesion: prostate metastasis, lymphoma, Paget\'s',
'Vertebral height: compression fracture, malignancy']),
('8', 'SUBDIAPHRAGMATIC', GOLD,
['Adrenal glands: enlargement / nodule (lung cancer staging)',
'Upper liver: mass, lesion',
'Stomach / upper kidney if included in scan',
'Free fluid / ascites']),
]
for num, title, col, items in checklist:
# Section header
hdr = Table([[
Paragraph(num, ParagraphStyle('cn', fontName='Helvetica-Bold', fontSize=11,
textColor=white, alignment=TA_CENTER)),
Paragraph(title, ParagraphStyle('ct', fontName='Helvetica-Bold', fontSize=9,
textColor=white, alignment=TA_LEFT)),
]], colWidths=[0.7*cm, 15.3*cm])
hdr.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), col),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (0, 0), 4),
('LEFTPADDING', (1, 0), (1, 0), 8),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]))
story.append(hdr)
# Items in 2 columns
mid = (len(items) + 1) // 2
left = items[:mid]
right = items[mid:]
while len(right) < len(left):
right.append('')
item_rows = []
for l, r in zip(left, right):
item_rows.append([
Paragraph(u'\u2610 ' + l, ST['bullet']) if l else Paragraph('', ST['small']),
Paragraph(u'\u2610 ' + r, ST['bullet']) if r else Paragraph('', ST['small']),
])
item_t = Table(item_rows, colWidths=[8*cm, 8*cm])
item_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), LGREY),
('GRID', (0, 0), (-1, -1), 0.3, HexColor('#D5D8DC')),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
]))
story.append(item_t)
story.append(Spacer(1, 4))
story.append(TipBox(
'Mnemonic: T-A-L-E-P-V-B (Trachea, Airspaces, Lymph nodes, Effusion, Pleura, Vessels, Bones)',
bg=LGOLD, border=GOLD, label='MEMORY AID'))
story.append(PageBreak())
# ── PAGE 10 – Quick Lookup Table ─────────────────────────────────────────
story.append(ColorBand(' SECTION 9 – QUICK PATTERN-DISEASE LOOKUP TABLE', bg=NAVY, height=30))
story.append(Spacer(1, 6))
story.append(Paragraph(
'Use this table to correlate HRCT findings with the most likely diagnosis. '
'Always correlate with clinical history, PFTs, and serological data.',
ST['body']))
story.append(Spacer(1, 8))
lookup_data = [
[Paragraph('HRCT Pattern', ST['table_h']),
Paragraph('Distribution', ST['table_h']),
Paragraph('Additional Feature', ST['table_h']),
Paragraph('TOP DIAGNOSIS', ST['table_h']),
Paragraph('Other Dx', ST['table_h'])],
# GGO group
[Paragraph('GGO (diffuse)', ST['table_cell']),
Paragraph('Bilateral basal', ST['table_cell']),
Paragraph('Crazy paving', ST['table_cell']),
Paragraph('Pulmonary alveolar proteinosis', ParagraphStyle('td', fontName='Helvetica-Bold', fontSize=8, textColor=TEAL)),
Paragraph('COVID-19 PNA, edema', ST['small'])],
[Paragraph('GGO', ST['table_cell']),
Paragraph('Bilateral perihilar', ST['table_cell']),
Paragraph('Bilateral effusions', ST['table_cell']),
Paragraph('Cardiogenic pulmonary edema', ParagraphStyle('td2', fontName='Helvetica-Bold', fontSize=8, textColor=TEAL)),
Paragraph('ARDS', ST['small'])],
[Paragraph('GGO + centrilobular\nnodules', ST['table_cell']),
Paragraph('Upper/mid, bilateral', ST['table_cell']),
Paragraph('Air trapping, spares\nbases', ST['table_cell']),
Paragraph('Hypersensitivity Pneumonitis', ParagraphStyle('td3', fontName='Helvetica-Bold', fontSize=8, textColor=TEAL)),
Paragraph('DIP, RB-ILD', ST['small'])],
# Reticulation group
[Paragraph('Reticulation +\nhoneycombing', ST['table_cell']),
Paragraph('Bilateral basal\nsubpleural', ST['table_cell']),
Paragraph('Traction bronchiectasis', ST['table_cell']),
Paragraph('UIP/IPF (Typical UIP)', ParagraphStyle('td4', fontName='Helvetica-Bold', fontSize=8, textColor=GREEN)),
Paragraph('Asbestosis, CTD-ILD', ST['small'])],
[Paragraph('Reticulation, GGO\n(no honeycomb)', ST['table_cell']),
Paragraph('Bilateral basal\n+ subpleural sparing', ST['table_cell']),
Paragraph('Subpleural sparing\nin 50%', ST['table_cell']),
Paragraph('NSIP', ParagraphStyle('td5', fontName='Helvetica-Bold', fontSize=8, textColor=GREEN)),
Paragraph('CTD, drug toxicity', ST['small'])],
# Consolidation group
[Paragraph('Peripheral\nconsolidation', ST['table_cell']),
Paragraph('Bilateral, lower\nmigrating', ST['table_cell']),
Paragraph('Reverse halo sign\n(atoll sign)', ST['table_cell']),
Paragraph('COP / BOOP', ParagraphStyle('td6', fontName='Helvetica-Bold', fontSize=8, textColor=ORANGE)),
Paragraph('Eosinophilic PNA', ST['small'])],
[Paragraph('Wedge consolidation\n(pleural based)', ST['table_cell']),
Paragraph('Peripheral', ST['table_cell']),
Paragraph("Hampton's hump;\nCTPA: filling defect", ST['table_cell']),
Paragraph('Pulmonary infarction (PE)', ParagraphStyle('td7', fontName='Helvetica-Bold', fontSize=8, textColor=RED)),
Paragraph('Pneumonia', ST['small'])],
# Nodule group
[Paragraph('Perilymphatic nodules', ST['table_cell']),
Paragraph('Upper zone, bilateral', ST['table_cell']),
Paragraph('Bilateral hilar nodes\n(1-2-3 sign)', ST['table_cell']),
Paragraph('Sarcoidosis', ParagraphStyle('td8', fontName='Helvetica-Bold', fontSize=8, textColor=PURPLE)),
Paragraph('Lymphangitic met.', ST['small'])],
[Paragraph('Random micronodules\n(<3 mm, uniform)', ST['table_cell']),
Paragraph('Diffuse, all zones', ST['table_cell']),
Paragraph('No zonal preference,\nvery uniform', ST['table_cell']),
Paragraph('Miliary TB / Miliary fungal', ParagraphStyle('td9', fontName='Helvetica-Bold', fontSize=8, textColor=PURPLE)),
Paragraph('Hematogenous mets', ST['small'])],
[Paragraph('Tree-in-bud +\ncentrilobular nodules', ST['table_cell']),
Paragraph('Any, often focal', ST['table_cell']),
Paragraph('Lobular consolidation', ST['table_cell']),
Paragraph('Endobronchial infection\n(TB, NTM, bacterial)', ParagraphStyle('td10', fontName='Helvetica-Bold', fontSize=8, textColor=ORANGE)),
Paragraph('Aspiration', ST['small'])],
# Cystic/low attenuation
[Paragraph('Emphysema (NO walls)\ncentrilobular', ST['table_cell']),
Paragraph('Upper lobe\nperiphery spared', ST['table_cell']),
Paragraph('Central dot (artery)\nSmoking history', ST['table_cell']),
Paragraph('Centrilobular emphysema', ParagraphStyle('td11', fontName='Helvetica-Bold', fontSize=8, textColor=MIDGREY)),
Paragraph('Paraseptal emphysema', ST['small'])],
[Paragraph('Multiple thin-walled\ncysts, uniform', ST['table_cell']),
Paragraph('Diffuse both lungs', ST['table_cell']),
Paragraph('Young woman,\npneumothorax, effusion', ST['table_cell']),
Paragraph('LAM', ParagraphStyle('td12', fontName='Helvetica-Bold', fontSize=8, textColor=MIDGREY)),
Paragraph('LIP, BHD syndrome', ST['small'])],
[Paragraph('Thick + thin cysts,\nbizarre shapes', ST['table_cell']),
Paragraph('Upper zone\nlung spare bases', ST['table_cell']),
Paragraph('Smoker, nodules early\nthen cavitate', ST['table_cell']),
Paragraph('LCH (Langerhans Cell Histiocytosis)', ParagraphStyle('td13', fontName='Helvetica-Bold', fontSize=8, textColor=MIDGREY)),
Paragraph('LAM, lymphoma', ST['small'])],
[Paragraph('Signet ring sign\n(dilated bronchi)', ST['table_cell']),
Paragraph('Any, often bilateral', ST['table_cell']),
Paragraph('Mucus plugging,\ncentral > cylindrical', ST['table_cell']),
Paragraph('Bronchiectasis\n(CF, ABPA, post-infective)', ParagraphStyle('td14', fontName='Helvetica-Bold', fontSize=8, textColor=NAVY)),
Paragraph('Traction bronchiect.', ST['small'])],
]
lookup_ts = TableStyle([
('BACKGROUND', (0, 0), (-1, 0), DARKGREY),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, LGREY]),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#BDC3C7')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 7.5),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
])
lookup_t = Table(lookup_data, colWidths=[3*cm, 2.8*cm, 3.2*cm, 4.5*cm, 2.5*cm])
lookup_t.setStyle(lookup_ts)
story.append(lookup_t)
story.append(Spacer(1, 8))
story.append(TipBox(
'Remember: HRCT diagnosis is ALWAYS clinical + radiological + PFT correlation. '
'A multidisciplinary team discussion (MDD) is the gold standard for ILD diagnosis.',
bg=LRED, border=RED, label='IMPORTANT'))
# ── Build ─────────────────────────────────────────────────────────────────
def make_template_callback(is_cover):
def callback(canvas, doc):
if is_cover:
cover_page(canvas, doc)
else:
page_header_footer(canvas, doc)
return callback
from reportlab.platypus import BaseDocTemplate, PageTemplate, Frame
from reportlab.platypus import NextPageTemplate
doc.build(
story,
onFirstPage=cover_page,
onLaterPages=page_header_footer,
)
print(f"PDF generated: {path}")
if __name__ == '__main__':
build_pdf('/home/daytona/workspace/hrct-guide/HRCT_CECT_Visual_Reference_Guide.pdf')