~/ecg-reference/build_ecg_reference.py
#!/usr/bin/env python3
"""
ECG Reference Card - 40+ Abnormalities with Diagnostic Criteria
Builds a professional multi-page PDF reference card using ReportLab.
"""
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import mm, cm
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether, PageBreak
)
from reportlab.platypus.flowables import BalancedColumns
from reportlab.lib.colors import HexColor, white, black
import os
OUTPUT = "/home/daytona/workspace/ecg-reference/ECG_Reference_Card.pdf"
# ── Colour palette ────────────────────────────────────────────────────────────
C_DARK_RED = HexColor("#8B0000")
C_RED = HexColor("#C0392B")
C_ORANGE = HexColor("#E67E22")
C_AMBER = HexColor("#F39C12")
C_BLUE_DARK = HexColor("#1A3A5C")
C_BLUE = HexColor("#2980B9")
C_BLUE_LIGHT = HexColor("#EBF5FB")
C_TEAL = HexColor("#117A65")
C_TEAL_LIGHT = HexColor("#E8F8F5")
C_PURPLE = HexColor("#6C3483")
C_PURPLE_LIGHT = HexColor("#F5EEF8")
C_GREEN = HexColor("#1E8449")
C_GREEN_LIGHT = HexColor("#EAFAF1")
C_GREY_DARK = HexColor("#2C3E50")
C_GREY = HexColor("#566573")
C_GREY_LIGHT = HexColor("#F2F3F4")
C_GREY_MID = HexColor("#BDC3C7")
C_YELLOW_LIGHT = HexColor("#FDFAE6")
C_PINK_LIGHT = HexColor("#FDEDEC")
# ── Page setup ────────────────────────────────────────────────────────────────
PAGE_W, PAGE_H = A4
MARGIN = 14 * mm
DOC_W = PAGE_W - 2 * MARGIN
# ── Styles ────────────────────────────────────────────────────────────────────
def make_styles():
base = getSampleStyleSheet()
S = {}
S['cover_title'] = ParagraphStyle(
'cover_title', fontName='Helvetica-Bold',
fontSize=32, textColor=white, alignment=TA_CENTER, leading=40,
spaceAfter=6
)
S['cover_sub'] = ParagraphStyle(
'cover_sub', fontName='Helvetica',
fontSize=13, textColor=HexColor("#D6EAF8"), alignment=TA_CENTER, leading=18
)
S['cover_source'] = ParagraphStyle(
'cover_source', fontName='Helvetica-Oblique',
fontSize=9, textColor=HexColor("#AED6F1"), alignment=TA_CENTER, leading=12
)
S['section_head'] = ParagraphStyle(
'section_head', fontName='Helvetica-Bold',
fontSize=12, textColor=white, alignment=TA_LEFT, leading=15,
leftIndent=4
)
S['card_title'] = ParagraphStyle(
'card_title', fontName='Helvetica-Bold',
fontSize=8.5, textColor=C_GREY_DARK, leading=11, spaceAfter=1
)
S['card_body'] = ParagraphStyle(
'card_body', fontName='Helvetica',
fontSize=7.5, textColor=C_GREY_DARK, leading=10.5, spaceAfter=0
)
S['card_crit'] = ParagraphStyle(
'card_crit', fontName='Helvetica-Bold',
fontSize=7.5, textColor=C_BLUE_DARK, leading=10.5
)
S['card_key'] = ParagraphStyle(
'card_key', fontName='Helvetica-BoldOblique',
fontSize=7.2, textColor=C_RED, leading=10
)
S['tag'] = ParagraphStyle(
'tag', fontName='Helvetica-Bold',
fontSize=6.5, textColor=white, leading=9, alignment=TA_CENTER
)
S['footer'] = ParagraphStyle(
'footer', fontName='Helvetica',
fontSize=7, textColor=C_GREY, alignment=TA_CENTER, leading=9
)
S['toc_head'] = ParagraphStyle(
'toc_head', fontName='Helvetica-Bold',
fontSize=10, textColor=C_BLUE_DARK, leading=13, spaceAfter=2
)
S['toc_item'] = ParagraphStyle(
'toc_item', fontName='Helvetica',
fontSize=8.5, textColor=C_GREY_DARK, leading=12, leftIndent=8
)
S['normal_head'] = ParagraphStyle(
'normal_head', fontName='Helvetica-Bold',
fontSize=9, textColor=C_BLUE_DARK, leading=12, spaceAfter=2, spaceBefore=4
)
S['normal_body'] = ParagraphStyle(
'normal_body', fontName='Helvetica',
fontSize=8, textColor=C_GREY_DARK, leading=11, spaceAfter=1
)
S['table_head'] = ParagraphStyle(
'table_head', fontName='Helvetica-Bold',
fontSize=7.5, textColor=white, leading=10, alignment=TA_CENTER
)
S['table_cell'] = ParagraphStyle(
'table_cell', fontName='Helvetica',
fontSize=7, textColor=C_GREY_DARK, leading=9.5
)
S['table_cell_bold'] = ParagraphStyle(
'table_cell_bold', fontName='Helvetica-Bold',
fontSize=7, textColor=C_GREY_DARK, leading=9.5
)
return S
S = make_styles()
# ── Helper: coloured section banner ──────────────────────────────────────────
def section_banner(title, color=C_BLUE_DARK, icon=""):
label = f"{icon} {title}" if icon else title
t = Table([[Paragraph(label, S['section_head'])]],
colWidths=[DOC_W])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), color),
('ROWBACKGROUNDS', (0, 0), (-1, -1), [color]),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('ROUNDEDCORNERS', [3, 3, 3, 3]),
]))
return t
# ── Helper: single condition card ────────────────────────────────────────────
def condition_card(num, name, criteria_lines, key_pearl, bg=C_BLUE_LIGHT, tag_color=C_BLUE):
"""Returns a Table that looks like a card."""
num_cell = Paragraph(f"<b>{num}</b>", ParagraphStyle(
'num', fontName='Helvetica-Bold', fontSize=10,
textColor=tag_color, alignment=TA_CENTER, leading=13
))
title_cell = Paragraph(f"<b>{name}</b>", S['card_title'])
criteria_text = "<br/>".join(
f"<bullet>•</bullet> {line}" for line in criteria_lines
)
crit_cell = Paragraph(criteria_text, S['card_body'])
pearl_cell = Paragraph(f"<i>★ {key_pearl}</i>", S['card_key'])
inner = Table(
[[title_cell], [crit_cell], [pearl_cell]],
colWidths=[DOC_W * 0.87]
)
inner.setStyle(TableStyle([
('TOPPADDING', (0, 0), (-1, -1), 1),
('BOTTOMPADDING', (0, 0), (-1, -1), 1),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
]))
outer = Table(
[[num_cell, inner]],
colWidths=[DOC_W * 0.07, DOC_W * 0.93]
)
outer.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), bg),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('LEFTPADDING', (0, 0), (0, 0), 4),
('LEFTPADDING', (1, 0), (1, 0), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOX', (0, 0), (-1, -1), 0.5, C_GREY_MID),
('LINEBELOW', (0, 0), (-1, -1), 0.3, C_GREY_MID),
]))
return outer
# ── Helper: two-column card row ───────────────────────────────────────────────
def two_col_cards(card_a, card_b):
gap = 3 * mm
col_w = (DOC_W - gap) / 2
t = Table([[card_a, card_b]], colWidths=[col_w, col_w])
t.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
('INNERGRID', (0, 0), (-1, -1), 0, white),
('COLUMNPADDING', (1, 0), (1, 0), gap / 2),
]))
return t
# ── Helper: mini single-column card (for 2-wide layout) ──────────────────────
def mini_card(num, name, criteria_lines, key_pearl, bg=C_BLUE_LIGHT, tag_color=C_BLUE):
col_w = (DOC_W - 3 * mm) / 2
num_cell = Paragraph(f"<b>{num}</b>", ParagraphStyle(
'num2', fontName='Helvetica-Bold', fontSize=9,
textColor=tag_color, alignment=TA_CENTER, leading=12
))
title_cell = Paragraph(f"<b>{name}</b>", S['card_title'])
criteria_text = "<br/>".join(
f"<bullet>•</bullet> {line}" for line in criteria_lines
)
crit_cell = Paragraph(criteria_text, S['card_body'])
pearl_cell = Paragraph(f"<i>★ {key_pearl}</i>", S['card_key'])
inner = Table(
[[title_cell], [crit_cell], [pearl_cell]],
colWidths=[col_w * 0.86]
)
inner.setStyle(TableStyle([
('TOPPADDING', (0, 0), (-1, -1), 1),
('BOTTOMPADDING', (0, 0), (-1, -1), 1),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
]))
outer = Table(
[[num_cell, inner]],
colWidths=[col_w * 0.09, col_w * 0.91]
)
outer.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), bg),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('LEFTPADDING', (0, 0), (0, 0), 3),
('LEFTPADDING', (1, 0), (1, 0), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOX', (0, 0), (-1, -1), 0.5, C_GREY_MID),
]))
return outer
# ── Helper: comparison table ──────────────────────────────────────────────────
def comparison_table(headers, rows, col_colors=None):
header_row = [Paragraph(h, S['table_head']) for h in headers]
data = [header_row]
for row in rows:
data.append([Paragraph(str(cell), S['table_cell']) for cell in row])
col_count = len(headers)
col_w = DOC_W / col_count
t = Table(data, colWidths=[col_w] * col_count, repeatRows=1)
style_cmds = [
('BACKGROUND', (0, 0), (-1, 0), C_BLUE_DARK),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 7.5),
('TEXTCOLOR', (0, 0), (-1, 0), white),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [white, C_GREY_LIGHT]),
('GRID', (0, 0), (-1, -1), 0.3, C_GREY_MID),
('FONTSIZE', (0, 1), (-1, -1), 7),
]
if col_colors:
for col_idx, col_color in col_colors.items():
style_cmds.append(('BACKGROUND', (col_idx, 0), (col_idx, 0), col_color))
t.setStyle(TableStyle(style_cmds))
return t
# ── Page numbering callback ───────────────────────────────────────────────────
def on_page(canvas, doc):
canvas.saveState()
# Footer line
canvas.setStrokeColor(C_BLUE_DARK)
canvas.setLineWidth(0.5)
canvas.line(MARGIN, 18 * mm, PAGE_W - MARGIN, 18 * mm)
# Footer text
canvas.setFont('Helvetica', 6.5)
canvas.setFillColor(C_GREY)
canvas.drawString(MARGIN, 13 * mm,
"ECG Reference Card | Based on Harrison's 22E, Goldman-Cecil Medicine, Medical Physiology")
canvas.drawRightString(PAGE_W - MARGIN, 13 * mm,
f"Page {doc.page}")
canvas.restoreState()
def on_first_page(canvas, doc):
# Full cover background
canvas.setFillColor(C_BLUE_DARK)
canvas.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)
# ECG waveform decoration strip (simulated)
canvas.setStrokeColor(HexColor("#1A5276"))
canvas.setLineWidth(1)
canvas.line(0, PAGE_H * 0.52, PAGE_W, PAGE_H * 0.52)
# ── All 40+ ECG conditions data ───────────────────────────────────────────────
CONDITIONS = {
"SINUS RHYTHMS": [
{
"num": "01", "name": "Normal Sinus Rhythm",
"criteria": [
"Rate 60-100 bpm",
"P wave before every QRS; every QRS after a P",
"P upright in I, II, aVF; inverted in aVR",
"PR 120-200 ms; QRS < 120 ms",
],
"pearl": "The reference standard. Deviation from this = abnormal.",
"bg": C_GREEN_LIGHT, "tag": C_GREEN
},
{
"num": "02", "name": "Sinus Bradycardia",
"criteria": [
"Rate < 60 bpm",
"All morphologies (P, PR, QRS) completely NORMAL",
"Just slow",
],
"pearl": "Athletes, beta-blockers, hypothyroidism, inferior MI, sick sinus.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
{
"num": "03", "name": "Sinus Tachycardia",
"criteria": [
"Rate > 100 bpm",
"All morphologies (P, PR, QRS) completely NORMAL",
"P wave always upright in II",
],
"pearl": "Always find the CAUSE. It is a symptom, never a primary diagnosis.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
{
"num": "04", "name": "Sinus Arrhythmia",
"criteria": [
"Rate varies with respiration (increases inspiration, decreases expiration)",
"R-R variation > 10% between fastest and slowest",
"All P wave morphologies and PR intervals identical",
],
"pearl": "Normal finding; common in young patients and athletes.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
],
"ATRIAL ABNORMALITIES": [
{
"num": "05", "name": "Right Atrial Overload (P-pulmonale)",
"criteria": [
"P wave amplitude >= 2.5 mm in II, III, aVF (tall peaked P)",
"P wave duration NORMAL (< 120 ms)",
"Large initial positive component of P in V1",
],
"pearl": "COPD, pulmonary HTN, PE, tricuspid stenosis.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
{
"num": "06", "name": "Left Atrial Abnormality (P-mitrale)",
"criteria": [
"P wave duration >= 120 ms with notched 'M-shape' in II",
"Biphasic P in V1: prominent DEEP negative terminal component (> 1 mm deep AND > 1 mm wide)",
"P wave amplitude usually normal",
],
"pearl": "Mitral stenosis, LV failure, hypertension, aortic stenosis.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
],
"CHAMBER HYPERTROPHY": [
{
"num": "07", "name": "Left Ventricular Hypertrophy (LVH)",
"criteria": [
"Sokolow-Lyon: S(V1) + R(V5 or V6) > 35 mm",
"Cornell: R(aVL) + S(V3) > 28 mm (M) or > 20 mm (F)",
"R in aVL alone > 11 mm",
"LV strain: ST depression + asymmetric T inversion in I, aVL, V5-V6",
"Often: LAD, left atrial abnormality",
],
"pearl": "Sensitivity only 30-50%. Specificity 85-95%. Echocardiography is definitive.",
"bg": C_PURPLE_LIGHT, "tag": C_PURPLE
},
{
"num": "08", "name": "Right Ventricular Hypertrophy (RVH)",
"criteria": [
"R >= S wave in V1 (tall R in V1) - HALLMARK",
"Right axis deviation (QRS axis > +90 degrees)",
"ST depression + T-wave inversion V1-V3 (RV strain)",
"Deep S waves in I, V5, V6",
"Right atrial overload often accompanies",
],
"pearl": "Pulmonary HTN, pulmonary stenosis, COPD, ASD, Tetralogy of Fallot.",
"bg": C_PURPLE_LIGHT, "tag": C_PURPLE
},
],
"AV CONDUCTION BLOCKS": [
{
"num": "09", "name": "1st Degree AV Block",
"criteria": [
"PR interval > 200 ms (> 5 small boxes)",
"Every P wave is followed by a QRS - NOTHING DROPPED",
"PR interval prolonged but CONSTANT",
"QRS usually narrow and normal",
],
"pearl": "Usually benign. Digoxin, beta-blockers, inferior MI, high vagal tone.",
"bg": C_YELLOW_LIGHT, "tag": C_AMBER
},
{
"num": "10", "name": "2nd Degree AV Block - Mobitz I (Wenckebach)",
"criteria": [
"PR interval progressively LENGTHENS with each beat",
"Until one P wave is not conducted (QRS dropped)",
"After dropped beat, PR resets to shortest, cycle repeats",
"RR intervals progressively SHORTEN before pause",
"GROUPED BEATING pattern - characteristic",
],
"pearl": "Benign; AV nodal level. Inferior MI (RCA). Usually no pacemaker.",
"bg": C_YELLOW_LIGHT, "tag": C_AMBER
},
{
"num": "11", "name": "2nd Degree AV Block - Mobitz II",
"criteria": [
"PR interval is CONSTANT (does NOT prolong)",
"Sudden unexpected non-conducted P wave (QRS dropped without warning)",
"Often associated with WIDE QRS complex",
"May be 2:1, 3:1 pattern",
],
"pearl": "DANGEROUS - infranodal. High risk progression to complete block. Pacemaker needed.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "12", "name": "3rd Degree (Complete) AV Block",
"criteria": [
"COMPLETE AV dissociation - P and QRS are INDEPENDENT",
"P wave rate > QRS rate (P marches faster)",
"Narrow QRS escape (junctional, 40-60 bpm) = proximal block",
"Wide QRS escape (ventricular, 20-40 bpm) = distal block",
"No fixed PR relationship anywhere on strip",
],
"pearl": "EMERGENCY. Stokes-Adams attack (syncope). Urgent temporary pacemaker.",
"bg": C_PINK_LIGHT, "tag": C_DARK_RED
},
],
"BUNDLE BRANCH BLOCKS": [
{
"num": "13", "name": "Right Bundle Branch Block (RBBB)",
"criteria": [
"QRS >= 120 ms (wide)",
"rSR' pattern in V1 ('rabbit ears' / M-shape) - HALLMARK",
"Wide slurred S wave in I, aVL, V5, V6",
"T-wave inversion in V1-V2 (secondary change - NORMAL)",
],
"pearl": "T-wave inversion in V1-V2 is NORMAL in RBBB - not ischemia. New RBBB = consider PE.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
{
"num": "14", "name": "Left Bundle Branch Block (LBBB)",
"criteria": [
"QRS >= 120 ms (wide)",
"Broad, monophasic R in I, aVL, V5-V6 (no septal q waves!)",
"rS or QS pattern in V1 (deep broad S or entirely negative)",
"T-wave inversion in I, aVL, V5-V6 (secondary - NORMAL)",
"Left axis deviation common",
],
"pearl": "New LBBB + chest pain = STEMI equivalent. No septal Q waves is the key clue.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE_DARK
},
{
"num": "15", "name": "Left Anterior Fascicular Block (LAFB)",
"criteria": [
"Left axis deviation (axis more negative than -45 degrees)",
"qR pattern in leads I and aVL",
"rS pattern in leads II, III, aVF",
"QRS slightly prolonged but < 120 ms",
],
"pearl": "Most common cause of marked LAD in adults.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
{
"num": "16", "name": "Left Posterior Fascicular Block (LPFB)",
"criteria": [
"Right axis deviation (axis > +110 degrees)",
"rS pattern in I and aVL; qR in II, III, aVF",
"QRS < 120 ms",
"Diagnosis of EXCLUSION (rule out RVH, PE, lateral MI first)",
],
"pearl": "Very rare as isolated finding. Dual blood supply makes it resistant to ischemia.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
],
"ISCHEMIA AND INFARCTION": [
{
"num": "17", "name": "Hyperacute T Waves (Earliest STEMI)",
"criteria": [
"Tall, broad-based, symmetric peaked T waves in ischemic territory",
"T wave taller than R wave in same lead",
"Occur within MINUTES of coronary occlusion",
"Regional distribution (not diffuse)",
],
"pearl": "Earliest ECG sign of STEMI. Can revert to normal if reperfusion is rapid.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "18", "name": "STEMI - Anterior (LAD)",
"criteria": [
"ST elevation (convex/tombstone) in V1-V4 (+/- I, aVL)",
"Reciprocal ST depression in II, III, aVF",
"Evolves: hyperacute T -> ST elevation -> T inversion -> Q waves",
"Pathological Q waves in V1-V4 (width >= 40 ms OR depth >= 25% R)",
],
"pearl": "Proximal LAD occlusion = V1-V6 + I + aVL elevation (widowmaker).",
"bg": C_PINK_LIGHT, "tag": C_DARK_RED
},
{
"num": "19", "name": "STEMI - Inferior (RCA/LCx)",
"criteria": [
"ST elevation in II, III, aVF",
"Reciprocal ST depression in I, aVL (often prominent)",
"III elevation > II elevation suggests RCA",
"Always check right-sided leads (V3R/V4R) for RV infarct",
],
"pearl": "Do right-sided leads in ALL inferior MI. RV infarct changes management entirely.",
"bg": C_PINK_LIGHT, "tag": C_DARK_RED
},
{
"num": "20", "name": "STEMI - Lateral (LCx)",
"criteria": [
"ST elevation in I, aVL, V5, V6",
"Reciprocal ST depression in V1-V3 (and sometimes II, III, aVF)",
],
"pearl": "High lateral MI (I, aVL only) can have very subtle, easily missed changes.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "21", "name": "STEMI - Posterior",
"criteria": [
"NO direct ST elevation in standard leads",
"Tall R wave in V1-V2 (posterior Q wave mirror image)",
"ST depression in V1-V3 (posterior ST elevation, mirror image)",
"Confirm with posterior leads V7-V9 (ST elevation >= 0.5 mm)",
],
"pearl": "The 'hidden STEMI'. Always flip V1-V3 upside down to visualise posterior MI.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "22", "name": "Right Ventricular Infarction",
"criteria": [
"In context of inferior STEMI (proximal RCA)",
"ST elevation >= 1 mm in V3R and/or V4R",
"Clinical: hypotension + JVD + clear lungs (Bezold-Jarisch triad)",
"Contra-indicated: Nitrates (preload-dependent ventricle)",
],
"pearl": "Fluids, NOT nitrates. Give IV fluids to maintain RV preload.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "23", "name": "NSTEMI / Unstable Angina",
"criteria": [
"ST depression (horizontal or downsloping >= 1 mm) in >= 2 contiguous leads",
"T-wave inversions in ischemic territory",
"Troponin elevated (NSTEMI) or normal (unstable angina)",
"No ST elevation, no Q waves",
],
"pearl": "Up to 40% of NSTEMIs have a NORMAL ECG. Troponin is essential.",
"bg": C_PINK_LIGHT, "tag": C_ORANGE
},
{
"num": "24", "name": "Wellens Syndrome (LAD Warning)",
"criteria": [
"Biphasic T waves (Type A) or deep symmetric T inversions (Type B) in V2-V3",
"Little or no chest pain at time of ECG",
"No significant ST elevation; no Q waves",
"Normal or slightly elevated troponin",
],
"pearl": "Critical proximal LAD stenosis. High risk of anterior STEMI. URGENT cath.",
"bg": C_PINK_LIGHT, "tag": C_ORANGE
},
{
"num": "25", "name": "De Winter T Waves (LAD Occlusion Equivalent)",
"criteria": [
"Upsloping ST depression >= 1-3 mm at J-point in V1-V6",
"Followed by tall, peaked, symmetric positive T waves",
"ST elevation in aVR (1-2 mm)",
"No classic ST elevation - but represents STEMI equivalent",
],
"pearl": "Seen in ~2% of LAD occlusions. Treat as anterior STEMI. Activate cath lab.",
"bg": C_PINK_LIGHT, "tag": C_ORANGE
},
],
"ATRIAL ARRHYTHMIAS": [
{
"num": "26", "name": "Atrial Premature Contractions (APCs)",
"criteria": [
"Early (premature) P wave with DIFFERENT morphology from sinus P",
"Followed by normal QRS (usually narrow)",
"Incomplete compensatory pause (sinus is reset)",
],
"pearl": "Benign. Triggered by caffeine, alcohol, stress, electrolyte imbalance.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
{
"num": "27", "name": "Atrial Flutter",
"criteria": [
"Sawtooth flutter waves (F waves) at 250-350 bpm (classically 300 bpm)",
"Best seen in II, III, aVF (negative sawtooth) and V1",
"Regular or regularly irregular ventricular rate (2:1, 3:1, 4:1)",
"Classic 2:1 flutter: ventricular rate exactly 150 bpm",
],
"pearl": "Any narrow tachycardia at EXACTLY 150 bpm = flutter until proven otherwise.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
{
"num": "28", "name": "Atrial Fibrillation (AF)",
"criteria": [
"No distinct P waves - chaotic fibrillatory baseline (fine or coarse)",
"IRREGULARLY IRREGULAR RR intervals - absolute hallmark",
"Narrow QRS (usually) unless aberrant conduction",
"Ventricular rate 100-170 bpm if uncontrolled",
],
"pearl": "The only 'truly irregular' rhythm. If RR is irregular + wide QRS = AF with BBB or WPW.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
],
"VENTRICULAR ARRHYTHMIAS": [
{
"num": "29", "name": "Premature Ventricular Contractions (PVCs)",
"criteria": [
"Wide QRS >= 120 ms with bizarre morphology",
"No preceding P wave",
"T wave DISCORDANT (opposite) to QRS main deflection",
"Full compensatory pause (P-P marches through undisturbed)",
],
"pearl": "Bigeminy = every other beat PVC; trigeminy = every third. Unifocal vs multifocal.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "30", "name": "Ventricular Tachycardia (VT)",
"criteria": [
"Wide QRS >= 120 ms tachycardia, rate > 100 bpm (usually 140-220 bpm)",
"AV dissociation (P waves march independently, SLOWER than QRS) - pathognomonic",
"Fusion beats (hybrid P+VT morphology) - confirms AV dissociation",
"Capture beats (narrow QRS amidst wide) - confirms AV dissociation",
"QRS concordance in precordial leads (all positive or all negative V1-V6)",
],
"pearl": "ALWAYS assume wide-complex tachycardia = VT. NEVER give verapamil empirically.",
"bg": C_PINK_LIGHT, "tag": C_DARK_RED
},
{
"num": "31", "name": "Ventricular Fibrillation (VF)",
"criteria": [
"Completely chaotic, irregular, high-frequency undulations",
"No recognisable QRS, ST, T waves",
"Rate: indeterminate (truly chaotic)",
],
"pearl": "Cardiac arrest. IMMEDIATE unsynchronised defibrillation. CPR concurrently.",
"bg": C_PINK_LIGHT, "tag": C_DARK_RED
},
{
"num": "32", "name": "Torsades de Pointes (TdP)",
"criteria": [
"Polymorphic VT with twisting QRS axis around isoelectric baseline",
"QRS amplitude oscillates - 'twisting of the points'",
"Occurs in context of PROLONGED QT interval",
"Rate 150-250 bpm; may self-terminate or degenerate to VF",
],
"pearl": "Treat with IV Magnesium FIRST, even if Mg level is normal. Correct K+ simultaneously.",
"bg": C_PINK_LIGHT, "tag": C_DARK_RED
},
],
"SVT / ACCESSORY PATHWAYS": [
{
"num": "33", "name": "AVNRT (Most Common SVT)",
"criteria": [
"Narrow QRS tachycardia, rate 150-250 bpm",
"P waves buried IN or immediately after QRS (retrograde P)",
"Pseudo-S wave in inferior leads; pseudo-R' in V1",
"Regular rhythm; abrupt onset and termination",
"RP interval < 70 ms",
],
"pearl": "Vagal manoeuvres or adenosine terminates it. Reentry within the AV node.",
"bg": C_PURPLE_LIGHT, "tag": C_PURPLE
},
{
"num": "34", "name": "WPW Syndrome (Resting ECG)",
"criteria": [
"Short PR < 120 ms (pre-excitation bypasses AV node)",
"Delta wave (slurred upstroke of QRS) - direct ventricular pre-excitation",
"Widened QRS > 120 ms (fusion: normal + accessory pathway activation)",
"Secondary ST-T changes (discordant to delta/QRS)",
],
"pearl": "WPW + AF = life-threatening (rate >300 bpm). NEVER give digoxin or verapamil.",
"bg": C_PURPLE_LIGHT, "tag": C_PURPLE
},
{
"num": "35", "name": "Orthodromic AVRT (WPW tachycardia)",
"criteria": [
"Narrow QRS tachycardia (down AV node, up accessory pathway)",
"Retrograde P wave AFTER QRS (RP > 70 ms, separated from QRS)",
"Regular rate 150-250 bpm",
"Rate 95% of WPW tachycardias",
],
"pearl": "Narrow QRS in WPW tachycardia. Adenosine often terminates it (terminates AV conduction).",
"bg": C_PURPLE_LIGHT, "tag": C_PURPLE
},
],
"ELECTROLYTE DISTURBANCES": [
{
"num": "36", "name": "Hyperkalemia",
"criteria": [
"K+ 5.5-6.5: Tall narrow symmetric PEAKED (tented) T waves - FIRST sign",
"K+ 6.5-7.5: PR prolongation, P wave flattening/disappearance",
"K+ 7.0-7.5: QRS widening begins",
"K+ 7.5-8.0: Markedly wide bizarre QRS",
"K+ > 8.0: Sine wave pattern -> VF -> asystole",
],
"pearl": "Peaked T + wide QRS + no P = emergency. IV calcium gluconate IMMEDIATELY.",
"bg": C_YELLOW_LIGHT, "tag": C_ORANGE
},
{
"num": "37", "name": "Hypokalemia",
"criteria": [
"Flat or inverted T waves",
"Prominent U waves (U wave taller than T wave in V2-V3) - HALLMARK",
"Apparent QT prolongation (actually QU prolongation)",
"ST depression",
],
"pearl": "Predisposes to Torsades de Pointes and digitalis toxicity. Replete K+ to > 4.0 mEq/L.",
"bg": C_YELLOW_LIGHT, "tag": C_AMBER
},
{
"num": "38", "name": "Hypercalcemia",
"criteria": [
"Shortened QT interval (abbreviated ST segment)",
"QTc < 360 ms",
"Osborn waves may appear in severe cases",
"T wave shape relatively normal",
],
"pearl": "Short QT = high calcium. Classic in hyperparathyroidism, malignancy.",
"bg": C_YELLOW_LIGHT, "tag": C_AMBER
},
{
"num": "39", "name": "Hypocalcemia",
"criteria": [
"Prolonged QT interval (specifically prolonged ST SEGMENT)",
"T wave shape itself remains relatively normal",
"QTc prolonged (> 450 ms men, > 460 ms women)",
],
"pearl": "QT is long but T-wave shape is normal - unlike drug QT where T wave is deformed.",
"bg": C_YELLOW_LIGHT, "tag": C_AMBER
},
],
"SPECIAL SYNDROMES": [
{
"num": "40", "name": "Acute Pericarditis",
"criteria": [
"Diffuse CONCAVE (saddle-shaped) ST elevation in ALMOST ALL leads",
"ST depression only in aVR and V1 (+ PR elevation in aVR)",
"PR DEPRESSION - pathognomonic sign",
"NO reciprocal ST depression (unlike MI)",
"NO Q waves",
],
"pearl": "Saddle shape + PR depression + no reciprocal changes = pericarditis, NOT STEMI.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
{
"num": "41", "name": "Cardiac Tamponade",
"criteria": [
"Sinus tachycardia (compensatory)",
"LOW QRS voltage (< 5 mm all limb leads; < 10 mm all precordial leads)",
"ELECTRICAL ALTERNANS - beat-to-beat alternation in QRS height/axis",
"All three together = highly specific for tamponade",
],
"pearl": "Alternans is caused by the heart swinging inside the pericardial effusion.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
{
"num": "42", "name": "Pulmonary Embolism",
"criteria": [
"Sinus tachycardia - most common (nonspecific)",
"S1Q3T3: S wave in I + Q wave in III + T inversion in III",
"New RBBB (acute RV pressure overload)",
"Right axis deviation; T inversions V1-V4",
"Sinus tachycardia may be the ONLY finding",
],
"pearl": "S1Q3T3 is specific but seen in only ~20%. Sinus tachycardia is the most common ECG sign.",
"bg": C_TEAL_LIGHT, "tag": C_TEAL
},
{
"num": "43", "name": "Brugada Syndrome",
"criteria": [
"Type 1 (diagnostic): >= 2 mm COVED (dome-shape) ST elevation in V1-V2",
"RBBB-like pattern in V1",
"Spontaneous or drug-provoked (flecainide challenge)",
"Type 2: Saddle-back pattern - NOT diagnostic without provocation",
],
"pearl": "SCN5A Na+ channel mutation. Causes VF during sleep/fever. ICD is treatment.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "44", "name": "Long QT Syndrome",
"criteria": [
"QTc >= 450 ms (men) or >= 460 ms (women)",
"LQT1: Broad-based wide T waves; triggered by exercise/swimming",
"LQT2: Bifid (notched) T waves; triggered by emotion/sudden sounds",
"LQT3: Late-onset narrow T wave; events during sleep",
],
"pearl": "Risk = Torsades de Pointes -> VF -> sudden death. Avoid QT-prolonging drugs.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
{
"num": "45", "name": "Hypothermia (Osborn Waves)",
"criteria": [
"Osborn (J) waves: positive convex hump at J-point (after QRS end)",
"Sinus bradycardia",
"All intervals prolonged (PR, QRS, QT)",
"J wave size correlates with degree of hypothermia",
],
"pearl": "Core temp < 35C. J wave amplitude increases as temperature drops.",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
{
"num": "46", "name": "Subarachnoid Hemorrhage (SAH)",
"criteria": [
"Deep, wide, symmetric T-wave inversions in multiple leads ('giant T waves')",
"Prolonged QT interval",
"May mimic anterior STEMI",
"Sinus bradycardia common",
],
"pearl": "CVA T-waves. Mediated by catecholamine surge. Check for headache ('thunderclap').",
"bg": C_BLUE_LIGHT, "tag": C_BLUE
},
{
"num": "47", "name": "Digoxin Effect/Toxicity",
"criteria": [
"Effect: Scooped ST-T ('reverse tick' sign); shortened QT; PR prolongation",
"Toxicity: Virtually any arrhythmia possible",
"Classic toxic rhythms: PVCs, AV block + junctional tachycardia, AF with regularised rhythm",
"Bidirectional VT = pathognomonic for digoxin toxicity",
],
"pearl": "Digoxin EFFECT (therapeutic) is not toxicity. Toxicity requires clinical correlation.",
"bg": C_YELLOW_LIGHT, "tag": C_AMBER
},
{
"num": "48", "name": "Tricyclic Antidepressant Toxicity",
"criteria": [
"QRS widening >= 100 ms (severe >= 160 ms)",
"Right axis deviation of terminal QRS forces",
"Tall R wave in aVR (> 3 mm); R:S in aVR > 0.7",
"QT prolongation; sinus tachycardia",
],
"pearl": "QRS > 100 ms = seizure risk. QRS > 160 ms = VT risk. Na bicarb is antidote.",
"bg": C_PINK_LIGHT, "tag": C_RED
},
]
}
# ── Build document ────────────────────────────────────────────────────────────
def build_pdf():
doc = SimpleDocTemplate(
OUTPUT,
pagesize=A4,
leftMargin=MARGIN,
rightMargin=MARGIN,
topMargin=22 * mm,
bottomMargin=22 * mm,
title="ECG Reference Card - 48 Abnormalities",
author="Orris Medical AI",
subject="ECG Diagnostic Criteria Reference",
)
story = []
# ── COVER PAGE ────────────────────────────────────────────────────────────
story.append(Spacer(1, 30 * mm))
cover_title_t = Table(
[[Paragraph("ECG Reference Card", S['cover_title'])]],
colWidths=[DOC_W]
)
cover_title_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_BLUE_DARK),
('TOPPADDING', (0, 0), (-1, -1), 12),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
story.append(cover_title_t)
story.append(Spacer(1, 4 * mm))
subtitle_t = Table(
[[Paragraph("48 ECG Abnormalities with Diagnostic Criteria", S['cover_sub'])]],
colWidths=[DOC_W]
)
subtitle_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_BLUE),
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
story.append(subtitle_t)
story.append(Spacer(1, 6 * mm))
desc_t = Table([[Paragraph(
"MBBS-Level Quick Reference | From Sinus Rhythms to Complex Arrhythmias<br/>"
"Ischemia & Infarction | Electrolyte Disorders | Special Syndromes<br/>"
"<br/>Based on: Harrison's Principles 22E | Goldman-Cecil Medicine | Medical Physiology (Boron & Boulpaep)",
S['cover_source']
)]], colWidths=[DOC_W])
desc_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_BLUE_DARK),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
]))
story.append(desc_t)
story.append(Spacer(1, 20 * mm))
# Colour legend on cover
legend_data = [
[
Paragraph("<b>Sinus / Normal</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
Paragraph("<b>Atrial Abnormalities</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
Paragraph("<b>Hypertrophy</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
Paragraph("<b>AV Blocks</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
],
[
Paragraph("<b>Bundle Branch</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
Paragraph("<b>Ischemia / MI</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
Paragraph("<b>Arrhythmias</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
Paragraph("<b>Special Syndromes</b>", ParagraphStyle('lg', fontName='Helvetica-Bold', fontSize=8, textColor=white)),
]
]
legend_t = Table(legend_data, colWidths=[DOC_W / 4] * 4)
legend_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), C_GREEN),
('BACKGROUND', (1, 0), (1, 0), C_TEAL),
('BACKGROUND', (2, 0), (2, 0), C_PURPLE),
('BACKGROUND', (3, 0), (3, 0), C_AMBER),
('BACKGROUND', (0, 1), (0, 1), C_BLUE),
('BACKGROUND', (1, 1), (1, 1), C_DARK_RED),
('BACKGROUND', (2, 1), (2, 1), C_RED),
('BACKGROUND', (3, 1), (3, 1), C_TEAL),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 7),
('BOTTOMPADDING', (0, 0), (-1, -1), 7),
('INNERGRID', (0, 0), (-1, -1), 0.5, white),
('BOX', (0, 0), (-1, -1), 0.5, white),
]))
story.append(legend_t)
story.append(Spacer(1, 10 * mm))
# Normal values box on cover
nv_data = [
[Paragraph("<b>NORMAL ECG VALUES QUICK REFERENCE</b>", ParagraphStyle(
'nvh', fontName='Helvetica-Bold', fontSize=9, textColor=white, alignment=TA_CENTER
))],
[Table([
[
Paragraph("<b>Heart Rate</b><br/>60-100 bpm", S['normal_body']),
Paragraph("<b>PR Interval</b><br/>120-200 ms (3-5 small boxes)", S['normal_body']),
Paragraph("<b>QRS Duration</b><br/>70-110 ms (< 3 small boxes)", S['normal_body']),
],
[
Paragraph("<b>QTc (Bazett)</b><br/>Men: <450 ms | Women: <460 ms", S['normal_body']),
Paragraph("<b>QRS Axis</b><br/>-30 to +90 degrees", S['normal_body']),
Paragraph("<b>ECG Paper</b><br/>1 small box = 0.04 s / 0.1 mV", S['normal_body']),
],
], colWidths=[DOC_W / 3] * 3)]
]
nv_outer = Table(nv_data, colWidths=[DOC_W])
nv_outer.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_BLUE_DARK),
('BACKGROUND', (0, 1), (-1, 1), HexColor("#EBF5FB")),
('TOPPADDING', (0, 0), (-1, 0), 6),
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
('TOPPADDING', (0, 1), (-1, 1), 4),
('BOTTOMPADDING', (0, 1), (-1, 1), 4),
('BOX', (0, 0), (-1, -1), 0.8, C_BLUE_DARK),
]))
story.append(nv_outer)
story.append(PageBreak())
# ── CONDITION PAGES ───────────────────────────────────────────────────────
section_colors = {
"SINUS RHYTHMS": C_GREEN,
"ATRIAL ABNORMALITIES": C_TEAL,
"CHAMBER HYPERTROPHY": C_PURPLE,
"AV CONDUCTION BLOCKS": C_AMBER,
"BUNDLE BRANCH BLOCKS": C_BLUE_DARK,
"ISCHEMIA AND INFARCTION": C_DARK_RED,
"ATRIAL ARRHYTHMIAS": C_TEAL,
"VENTRICULAR ARRHYTHMIAS": C_RED,
"SVT / ACCESSORY PATHWAYS": C_PURPLE,
"ELECTROLYTE DISTURBANCES": C_ORANGE,
"SPECIAL SYNDROMES": C_BLUE_DARK,
}
section_icons = {
"SINUS RHYTHMS": "01",
"ATRIAL ABNORMALITIES": "02",
"CHAMBER HYPERTROPHY": "03",
"AV CONDUCTION BLOCKS": "04",
"BUNDLE BRANCH BLOCKS": "05",
"ISCHEMIA AND INFARCTION": "06",
"ATRIAL ARRHYTHMIAS": "07",
"VENTRICULAR ARRHYTHMIAS": "08",
"SVT / ACCESSORY PATHWAYS": "09",
"ELECTROLYTE DISTURBANCES": "10",
"SPECIAL SYNDROMES": "11",
}
for section_name, conditions in CONDITIONS.items():
sec_color = section_colors.get(section_name, C_BLUE_DARK)
story.append(section_banner(
f"SECTION {section_icons.get(section_name, '')} | {section_name}",
color=sec_color
))
story.append(Spacer(1, 2 * mm))
# Render cards in two-column pairs
cards = []
for cond in conditions:
card = mini_card(
num=cond["num"],
name=cond["name"],
criteria_lines=cond["criteria"],
key_pearl=cond["pearl"],
bg=cond.get("bg", C_BLUE_LIGHT),
tag_color=cond.get("tag", C_BLUE)
)
cards.append(card)
# Pair up cards into two-column rows
i = 0
while i < len(cards):
if i + 1 < len(cards):
row_t = Table(
[[cards[i], Spacer(3 * mm, 1), cards[i + 1]]],
colWidths=[(DOC_W - 3 * mm) / 2, 3 * mm, (DOC_W - 3 * mm) / 2]
)
row_t.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
]))
story.append(row_t)
i += 2
else:
# Odd card - full width
full_card = condition_card(
num=conditions[i]["num"],
name=conditions[i]["name"],
criteria_lines=conditions[i]["criteria"],
key_pearl=conditions[i]["pearl"],
bg=conditions[i].get("bg", C_BLUE_LIGHT),
tag_color=conditions[i].get("tag", C_BLUE)
)
story.append(full_card)
story.append(Spacer(1, 1.5 * mm))
i += 1
story.append(Spacer(1, 4 * mm))
# ── COMPARISON TABLES PAGE ────────────────────────────────────────────────
story.append(PageBreak())
story.append(section_banner("CRITICAL COMPARISON TABLES", color=C_BLUE_DARK))
story.append(Spacer(1, 3 * mm))
# Table 1: STEMI localisation
story.append(Paragraph("STEMI Localisation by Lead Territory", S['normal_head']))
t1 = comparison_table(
headers=["MI Territory", "ST Elevation in", "Reciprocal Depression", "Culprit Artery"],
rows=[
["Anterior", "V1-V4", "II, III, aVF", "LAD (mid-distal)"],
["Septal", "V1-V2", "None typically", "LAD (septal branches)"],
["Anterolateral", "V1-V6, I, aVL", "II, III, aVF", "Proximal LAD"],
["Lateral", "I, aVL, V5-V6", "V1-V3", "LCx or Diagonal"],
["Inferior", "II, III, aVF", "I, aVL", "RCA (80%) or LCx (20%)"],
["Posterior", "Tall R + ST depression V1-V3", "None (V1-V3 shows depression)", "RCA or LCx"],
["Right Ventricle", "V3R, V4R elevation", "—", "Proximal RCA"],
]
)
story.append(t1)
story.append(Spacer(1, 4 * mm))
# Table 2: AV blocks
story.append(Paragraph("AV Block Differentiation", S['normal_head']))
t2 = comparison_table(
headers=["Block Type", "PR Interval", "Dropped QRS?", "Location", "Prognosis"],
rows=[
["1st Degree", "Fixed, > 200 ms", "Never", "AV node", "Benign"],
["2nd Degree - Mobitz I", "Progressively lengthens", "Yes (after longest PR)", "AV node", "Usually benign"],
["2nd Degree - Mobitz II", "Fixed, constant", "Yes (sudden, no warning)", "His/Bundle branch", "Dangerous - pace"],
["3rd Degree (Complete)", "No relationship (AV dissociation)", "Essentially all", "Anywhere", "Emergency - pace"],
]
)
story.append(t2)
story.append(Spacer(1, 4 * mm))
# Table 3: Pericarditis vs STEMI
story.append(Paragraph("Pericarditis vs STEMI - Key Differentiators", S['normal_head']))
t3 = comparison_table(
headers=["Feature", "Acute Pericarditis", "STEMI"],
rows=[
["ST elevation shape", "Concave/saddle-shaped (upward concave)", "Convex/tombstone (upward dome)"],
["Distribution", "Diffuse (almost ALL leads)", "Regional/territorial (contiguous leads)"],
["Reciprocal ST depression", "ABSENT (only aVR/V1 show depression)", "PRESENT in opposite territory"],
["PR changes", "PR DEPRESSION (pathognomonic) + PR elevation in aVR", "None"],
["Q waves", "Absent", "Develop (mark necrosis)"],
["Evolution", "4 staged over days-weeks", "Hours-to-days"],
]
)
story.append(t3)
story.append(Spacer(1, 4 * mm))
# Table 4: Wide-complex tachycardia
story.append(Paragraph("Wide-Complex Tachycardia: VT vs SVT with Aberrancy", S['normal_head']))
t4 = comparison_table(
headers=["Feature", "Favours VT", "Favours SVT + Aberrancy"],
rows=[
["AV dissociation", "YES - pathognomonic for VT", "Absent"],
["Fusion/capture beats", "YES - confirm VT", "Absent"],
["QRS concordance (V1-V6)", "All positive or all negative = VT", "Mixed polarity"],
["QRS morphology", "Unusual/bizarre (does not fit RBBB or LBBB)", "Typical RBBB or LBBB pattern"],
["History", "Structural heart disease, prior MI, cardiomyopathy", "Young patient, prior SVT episodes"],
["Default rule", "ALWAYS assume VT first!", "Only after VT excluded"],
]
)
story.append(t4)
story.append(Spacer(1, 4 * mm))
# Table 5: Electrolytes and QT
story.append(Paragraph("Electrolyte Effects on ECG", S['normal_head']))
t5 = comparison_table(
headers=["Electrolyte", "QT Effect", "T Wave", "P Wave/QRS", "Key Sign"],
rows=[
["Hyperkalemia (mild)", "Short/normal", "Peaked, tented, narrow, symmetric", "Normal", "Tent-shaped T"],
["Hyperkalemia (severe)", "QRS widens/absorbs QT", "Disappears into QRS", "P flattens, disappears; QRS widens", "Sine wave pattern"],
["Hypokalemia", "Prolonged (QU)", "Flat/inverted", "Normal", "Prominent U wave > T wave"],
["Hypercalcemia", "SHORTENED", "Normal or early takeoff", "Normal", "Short QTc < 360 ms"],
["Hypocalcemia", "PROLONGED (ST lengthened)", "Normal shape, just delayed onset", "Normal", "Long QTc, normal T shape"],
["Hypomagnesemia", "Prolonged", "Flat, may invert", "Normal", "Predisposes to TdP like hypoK+"],
]
)
story.append(t5)
# ── LIFE-THREATENING DIAGNOSES PAGE ──────────────────────────────────────
story.append(PageBreak())
story.append(section_banner(
"THE 5 IMMEDIATELY LIFE-THREATENING ECG DIAGNOSES",
color=C_DARK_RED
))
story.append(Spacer(1, 3 * mm))
life_data = [
[
Paragraph("<b>#</b>", S['table_head']),
Paragraph("<b>Diagnosis</b>", S['table_head']),
Paragraph("<b>ECG Key Finding</b>", S['table_head']),
Paragraph("<b>Immediate Action</b>", S['table_head']),
],
["1", "STEMI",
"Regional convex ST elevation in contiguous leads + reciprocal changes",
"Activate cath lab NOW. Aspirin + heparin + P2Y12 inhibitor. PCI within 90 min."],
["2", "Ventricular Fibrillation",
"Chaotic irregular undulations; no recognisable QRS/P/T",
"CPR + immediate unsynchronised DEFIBRILLATION 200J biphasic. Adrenaline 1mg IV."],
["3", "Ventricular Tachycardia (haemodynamically unstable)",
"Wide QRS > 120 ms tachycardia; AV dissociation; fusion/capture beats",
"Synchronised DC cardioversion. If pulseless: defibrillate. Amiodarone 300mg IV."],
["4", "Complete Heart Block",
"AV dissociation (P rate > QRS rate); wide slow escape rhythm",
"Atropine 0.5-1mg IV (if narrow QRS escape). Transcutaneous/transvenous pacing urgently."],
["5", "Severe Hyperkalemia (> 7.5 mEq/L)",
"Wide QRS + absent P waves + sine wave pattern",
"IV Calcium gluconate 10 mL 10% IMMEDIATELY. Insulin-dextrose. Bicarbonate. Dialysis."],
]
life_t = Table(life_data,
colWidths=[DOC_W * 0.04, DOC_W * 0.15, DOC_W * 0.40, DOC_W * 0.41],
repeatRows=1)
life_style = [
('BACKGROUND', (0, 0), (-1, 0), C_DARK_RED),
('TEXTCOLOR', (0, 0), (-1, 0), white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 8),
('FONTSIZE', (0, 1), (-1, -1), 7.5),
('ALIGN', (0, 0), (0, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
('GRID', (0, 0), (-1, -1), 0.5, C_GREY_MID),
('BACKGROUND', (0, 1), (-1, 1), HexColor("#FDEDEC")),
('BACKGROUND', (0, 2), (-1, 2), HexColor("#FEF9E7")),
('BACKGROUND', (0, 3), (-1, 3), HexColor("#FDEDEC")),
('BACKGROUND', (0, 4), (-1, 4), HexColor("#FEF9E7")),
('BACKGROUND', (0, 5), (-1, 5), HexColor("#FDEDEC")),
('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
('FONTNAME', (1, 1), (1, -1), 'Helvetica-Bold'),
('TEXTCOLOR', (1, 1), (1, -1), C_DARK_RED),
]
life_t.setStyle(TableStyle(life_style))
story.append(life_t)
story.append(Spacer(1, 5 * mm))
story.append(section_banner("SYSTEMATIC ECG READING APPROACH (Harrison's 22E - 14 Parameters)", color=C_BLUE_DARK))
story.append(Spacer(1, 3 * mm))
steps = [
("1", "Calibration & Technical Quality", "Confirm 25 mm/sec speed, 10 mm/mV gain. Check lead placement. Look for artefacts."),
("2", "Rhythm", "Regular or irregular? Any pattern to irregularity? Identify all wave types."),
("3", "Heart Rate", "300 / large boxes between RRs (regular). Or count QRS in 10 sec x 6 (irregular)."),
("4", "PR Interval / AV Conduction", "120-200 ms. Fixed? Lengthening? Every P followed by QRS?"),
("5", "QRS Duration", "< 120 ms = narrow. >= 120 ms = wide (BBB, VT, paced, hyperK+, toxic)."),
("6", "QT / QTc", "QTc = QT / sqrt(RR in seconds). Men < 450 ms; Women < 460 ms."),
("7", "Mean QRS Axis", "Check leads I and aVF: +/+ = normal; +/- = LAD; -/+ = RAD; -/- = extreme."),
("8", "P Wave Morphology", "Upright I, II, aVF. Peaked? Broad/notched? Biphasic V1?"),
("9", "QRS Voltages", "LVH criteria? Low voltage (< 5 mm limb, < 10 mm precordial)?"),
("10", "R-Wave Progression", "R grows from V1 to V5. Transition at V3-V4. Absent = anterior pathology."),
("11", "Abnormal Q Waves", "Width >= 40 ms OR depth >= 25% R wave height = pathological."),
("12", "ST Segments", "Elevation? Depression? Shape? Regional or diffuse? Measure at J-point."),
("13", "T Waves", "Inverted (ischaemia, strain, BBB)? Peaked (hyperK+)? Bifid (LQT2)?"),
("14", "U Waves", "Prominent U > T wave = hypoK+. Inverted U wave = ischaemia."),
]
step_data = [[
Paragraph(f"<b>{s}</b>", ParagraphStyle('sn', fontName='Helvetica-Bold', fontSize=8, textColor=white, alignment=TA_CENTER)),
Paragraph(f"<b>{n}</b>", ParagraphStyle('sc', fontName='Helvetica-Bold', fontSize=7.5, textColor=C_BLUE_DARK)),
Paragraph(desc, S['table_cell'])
] for s, n, desc in steps]
# Add header
step_data.insert(0, [
Paragraph("<b>#</b>", S['table_head']),
Paragraph("<b>Parameter</b>", S['table_head']),
Paragraph("<b>What to Look For</b>", S['table_head']),
])
step_t = Table(step_data,
colWidths=[DOC_W * 0.05, DOC_W * 0.25, DOC_W * 0.70],
repeatRows=1)
step_t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_BLUE_DARK),
('TEXTCOLOR', (0, 0), (-1, 0), white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 8),
('FONTSIZE', (0, 1), (-1, -1), 7.5),
('BACKGROUND', (0, 1), (0, -1), C_BLUE),
('TEXTCOLOR', (0, 1), (0, -1), white),
('ALIGN', (0, 0), (0, -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),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('ROWBACKGROUNDS', (1, 1), (-1, -1), [white, C_GREY_LIGHT]),
('GRID', (0, 0), (-1, -1), 0.3, C_GREY_MID),
]))
story.append(step_t)
# ── BUILD ─────────────────────────────────────────────────────────────────
doc.build(story, onFirstPage=on_page, onLaterPages=on_page)
print(f"PDF saved to: {OUTPUT}")
return OUTPUT
if __name__ == "__main__":
build_pdf()