Here is a comprehensive guide to 10 common heart diseases with their characteristic ECG changes and illustrative ECG images.
Generate a PDF reference card with ECG patterns and diagnostic criteria
ls -la /home/daytona/workspace/ 2>/dev/null || echo "workspace empty or missing"
~/ecg-reference/build_ecg_pdf.py
"""
ECG Reference Card PDF Generator
Uses ReportLab for professional multi-page PDF layout.
"""
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import mm, cm
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, PageBreak, KeepTogether
)
from reportlab.platypus.flowables import Flowable
from reportlab.graphics.shapes import Drawing, Rect, Line, String, Path
from reportlab.graphics import renderPDF
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import io
import urllib.request
import os
# ── Color palette ──────────────────────────────────────────────────────────────
NAVY = colors.HexColor("#0D2B4E")
CRIMSON = colors.HexColor("#C0392B")
TEAL = colors.HexColor("#1A6E7A")
GOLD = colors.HexColor("#D4A017")
LIGHT_BLUE = colors.HexColor("#EAF4FB")
LIGHT_GRAY = colors.HexColor("#F5F5F5")
MID_GRAY = colors.HexColor("#CCCCCC")
DARK_GRAY = colors.HexColor("#444444")
WHITE = colors.white
ECG_RED = colors.HexColor("#CC2222")
ECG_BLUE = colors.HexColor("#1A3A8A")
GRID_COLOR = colors.HexColor("#FFCCCC")
OUTPUT = "/home/daytona/workspace/ecg-reference/ECG_Reference_Card.pdf"
W, H = A4 # 595.27 x 841.89 pts
# ── Styles ────────────────────────────────────────────────────────────────────
styles = getSampleStyleSheet()
def S(name, **kw):
return ParagraphStyle(name, **kw)
TITLE_STYLE = S("Title", fontName="Helvetica-Bold", fontSize=22, textColor=WHITE,
alignment=TA_CENTER, spaceAfter=4)
SUB_STYLE = S("Sub", fontName="Helvetica", fontSize=10, textColor=colors.HexColor("#D0E8F5"),
alignment=TA_CENTER, spaceAfter=2)
HDR_STYLE = S("Hdr", fontName="Helvetica-Bold", fontSize=12, textColor=WHITE,
alignment=TA_LEFT, spaceBefore=0, spaceAfter=0, leftPadding=6)
BODY_STYLE = S("Body", fontName="Helvetica", fontSize=8, textColor=DARK_GRAY,
leading=11, spaceAfter=2)
SMALL_STYLE = S("Small", fontName="Helvetica", fontSize=7, textColor=DARK_GRAY,
leading=10)
BOLD_STYLE = S("Bold", fontName="Helvetica-Bold", fontSize=8, textColor=NAVY,
leading=11)
CAPTION = S("Caption",fontName="Helvetica-Oblique", fontSize=6.5,
textColor=colors.HexColor("#666666"), alignment=TA_CENTER, spaceAfter=2)
FOOT_STYLE = S("Foot", fontName="Helvetica", fontSize=6.5, textColor=MID_GRAY,
alignment=TA_CENTER)
CRIT_STYLE = S("Crit", fontName="Helvetica-Bold", fontSize=7.5,
textColor=CRIMSON, leading=10)
NOTE_STYLE = S("Note", fontName="Helvetica-Oblique", fontSize=7,
textColor=TEAL, leading=10)
# ── ECG waveform drawing helpers ───────────────────────────────────────────────
def ecg_grid(d, x, y, w, h, rows=4, cols=5):
"""Draw ECG paper grid."""
d.add(Rect(x, y, w, h, fillColor=colors.HexColor("#FFF8F8"),
strokeColor=colors.HexColor("#FFBBBB"), strokeWidth=0.3))
# minor grid
small = w / (cols * 5)
for i in range(cols * 5 + 1):
lw = 0.4 if i % 5 == 0 else 0.15
lc = colors.HexColor("#FF9999") if i % 5 == 0 else colors.HexColor("#FFCCCC")
d.add(Line(x + i*small, y, x + i*small, y + h, strokeColor=lc, strokeWidth=lw))
row_h = h / (rows * 5)
for i in range(rows * 5 + 1):
lw = 0.4 if i % 5 == 0 else 0.15
lc = colors.HexColor("#FF9999") if i % 5 == 0 else colors.HexColor("#FFCCCC")
d.add(Line(x, y + i*row_h, x + w, y + i*row_h, strokeColor=lc, strokeWidth=lw))
def draw_ecg_line(d, pts, color=ECG_RED, lw=1.2):
"""Draw a polyline from a list of (x,y) tuples."""
if len(pts) < 2:
return
p = Path(strokeColor=color, strokeWidth=lw, fillColor=None)
p.moveTo(pts[0][0], pts[0][1])
for px, py in pts[1:]:
p.lineTo(px, py)
d.add(p)
def normal_qrs(cx, cy, scale=1.0, color=ECG_RED):
"""Return points for a single normal PQRST complex centered near (cx,cy)."""
s = scale
pts = [
(cx - 22*s, cy),
(cx - 18*s, cy + 3*s), # P start
(cx - 14*s, cy + 6*s), # P peak
(cx - 10*s, cy + 3*s), # P end
(cx - 6*s, cy), # PR segment
(cx - 3*s, cy - 2*s), # Q
(cx, cy + 20*s), # R peak
(cx + 3*s, cy - 4*s), # S
(cx + 6*s, cy), # ST start
(cx + 12*s, cy + 4*s), # T start
(cx + 17*s, cy + 7*s), # T peak
(cx + 22*s, cy + 3*s), # T end
(cx + 28*s, cy),
]
return pts
# ── ECG Diagram functions (each returns a Drawing) ────────────────────────────
def make_normal_ecg(w=160, h=50):
"""Normal sinus rhythm strip."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.40
pts = []
for i in range(3):
cx = 20 + i * 48
pts += normal_qrs(cx, baseline, scale=0.80)
if i < 2:
pts.append((cx + 30, baseline))
draw_ecg_line(d, pts, ECG_RED)
return d
def make_afib_ecg(w=160, h=50):
"""Atrial fibrillation - irregular QRS, no P waves, fibrillatory baseline."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.42
import math, random
random.seed(42)
# fibrillatory baseline
fib_pts = []
for x in range(int(w)):
noise = math.sin(x * 0.9) * 1.5 + math.sin(x * 1.7) * 1.0 + math.sin(x * 3.1) * 0.7
fib_pts.append((x, baseline + noise))
draw_ecg_line(d, fib_pts, colors.HexColor("#FF6666"), lw=0.7)
# irregular QRS - variable spacing
qrs_x = [22, 46, 73, 94, 118, 140]
for cx in qrs_x:
qrs = [
(cx - 2, baseline),
(cx - 1, baseline - 2),
(cx, baseline + 16),
(cx + 1, baseline - 5),
(cx + 3, baseline),
]
draw_ecg_line(d, qrs, ECG_BLUE, lw=1.4)
d.add(String(4, h - 8, "II", fillColor=NAVY, fontSize=7,
fontName="Helvetica-Bold"))
return d
def make_aflutter_ecg(w=160, h=50):
"""Atrial flutter - sawtooth F-waves, 2:1 block."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.45
# sawtooth
saw = []
x = 4
while x < w - 10:
saw += [(x, baseline), (x + 6, baseline + 8), (x + 12, baseline)]
x += 12
draw_ecg_line(d, saw, colors.HexColor("#DD4444"), lw=0.9)
# QRS every 2nd flutter wave
for cx in [28, 52, 76, 100, 124, 148]:
qrs = [
(cx - 2, baseline),
(cx, baseline + 18),
(cx + 2, baseline - 4),
(cx + 4, baseline),
(cx + 8, baseline + 5),
(cx + 12, baseline),
]
draw_ecg_line(d, qrs, ECG_BLUE, lw=1.4)
d.add(String(4, h - 8, "II", fillColor=NAVY, fontSize=7,
fontName="Helvetica-Bold"))
return d
def make_vfib_ecg(w=160, h=50):
"""Ventricular fibrillation - chaotic oscillations."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
import math, random
random.seed(7)
baseline = h * 0.5
pts = []
for x in range(int(w)):
chaos = (math.sin(x * 0.3) * 9 + math.sin(x * 0.7 + 1) * 7
+ math.sin(x * 1.3 + 2) * 5 + math.sin(x * 2.1) * 3
+ (random.random() - 0.5) * 4)
pts.append((x, baseline + chaos))
draw_ecg_line(d, pts, ECG_RED, lw=1.0)
d.add(String(4, h - 8, "II", fillColor=NAVY, fontSize=7,
fontName="Helvetica-Bold"))
return d
def make_vtach_ecg(w=160, h=50):
"""Ventricular tachycardia - wide, rapid, regular QRS."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.45
import math
pts = [(0, baseline)]
period = 22
for i in range(7):
cx = 8 + i * period
pts += [
(cx, baseline),
(cx + 3, baseline + 16),
(cx + 6, baseline + 16),
(cx + 9, baseline - 10),
(cx + 12, baseline - 10),
(cx + 15, baseline + 5),
(cx + 18, baseline + 5),
(cx + 20, baseline),
]
draw_ecg_line(d, pts, ECG_BLUE, lw=1.3)
d.add(String(4, h - 8, "II", fillColor=NAVY, fontSize=7,
fontName="Helvetica-Bold"))
return d
def make_stemi_ecg(w=160, h=60):
"""STEMI - ST elevation pattern."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.38
st_elev = 12 # ST elevation in pts
pts = []
for i in range(3):
cx = 18 + i * 50
pts += [
(cx - 14, baseline),
(cx - 10, baseline + 5), # P
(cx - 6, baseline + 2),
(cx - 3, baseline - 2), # Q
(cx, baseline + 22), # R tall
(cx + 3, baseline + st_elev), # S (elevated)
(cx + 8, baseline + st_elev + 3), # ST elevated
(cx + 13, baseline + st_elev + 1),
(cx + 17, baseline - 3), # T inversion
(cx + 22, baseline),
]
if i < 2:
pts.append((cx + 28, baseline))
draw_ecg_line(d, pts, ECG_RED, lw=1.3)
# label ST elevation arrow
d.add(Line(18, baseline + st_elev, 18, baseline,
strokeColor=colors.HexColor("#006600"), strokeWidth=1.0,
strokeDashArray=[2, 2]))
d.add(String(20, baseline + st_elev/2, "↑ST", fillColor=colors.HexColor("#006600"),
fontSize=6, fontName="Helvetica-Bold"))
d.add(String(4, h - 8, "V4", fillColor=NAVY, fontSize=7, fontName="Helvetica-Bold"))
return d
def make_first_degree_block_ecg(w=160, h=50):
"""First-degree AV block - long PR interval."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.40
# Long PR = wider gap between P and QRS
for i in range(3):
cx = 22 + i * 50
p_start = cx - 24
pts = [
(p_start, baseline),
(p_start + 4, baseline + 5), # P peak
(p_start + 8, baseline), # P end
# long PR segment
(cx - 6, baseline),
(cx - 3, baseline - 2), # Q
(cx, baseline + 20), # R
(cx + 3, baseline - 4), # S
(cx + 6, baseline),
(cx + 11, baseline + 6), # T
(cx + 15, baseline),
]
draw_ecg_line(d, pts, ECG_RED, lw=1.2)
# PR bracket
if i == 1:
d.add(Line(p_start, baseline - 8, cx, baseline - 8,
strokeColor=TEAL, strokeWidth=0.8))
d.add(String(p_start + 4, baseline - 15, "PR > 200ms",
fillColor=TEAL, fontSize=6, fontName="Helvetica-Bold"))
d.add(String(4, h - 8, "II", fillColor=NAVY, fontSize=7, fontName="Helvetica-Bold"))
return d
def make_complete_block_ecg(w=160, h=60):
"""Third-degree (complete) AV block - P-QRS dissociation."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.45
# P waves at ~100 bpm (fast)
p_xs = [10, 22, 34, 46, 58, 70, 82, 94, 106, 118, 130, 142, 154]
for px in p_xs:
draw_ecg_line(d, [(px, baseline), (px+3, baseline+6),
(px+6, baseline)], colors.HexColor("#AA3300"), lw=0.9)
# QRS at ~40 bpm (slow) - wide
qrs_xs = [18, 58, 98, 138]
for cx in qrs_xs:
draw_ecg_line(d, [
(cx, baseline), (cx+2, baseline - 3),
(cx+4, baseline + 14), (cx+6, baseline - 5),
(cx+10, baseline), (cx+14, baseline + 4), (cx+18, baseline)
], ECG_BLUE, lw=1.4)
# labels
d.add(String(4, h - 8, "aVF", fillColor=NAVY, fontSize=7, fontName="Helvetica-Bold"))
d.add(String(72, h - 8, "Atrial: 107/min Ventricular: 43/min",
fillColor=DARK_GRAY, fontSize=5.5, fontName="Helvetica-Oblique"))
return d
def make_wenckebach_ecg(w=160, h=55):
"""Second-degree AV block Mobitz I (Wenckebach)."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.45
# 3 beats then dropped: PR progressively longer
pr_lengths = [8, 13, 18] # increasing PR
cx_starts = [14, 50, 92]
for idx, (cx, pr) in enumerate(zip(cx_starts, pr_lengths)):
# P wave
draw_ecg_line(d, [(cx, baseline), (cx+3, baseline+7), (cx+6, baseline)],
colors.HexColor("#AA3300"), lw=1.0)
# QRS after PR gap
qcx = cx + pr
draw_ecg_line(d, [
(qcx, baseline), (qcx+1, baseline-2),
(qcx+2, baseline+16), (qcx+3, baseline-4),
(qcx+5, baseline), (qcx+9, baseline+6), (qcx+13, baseline)
], ECG_BLUE, lw=1.3)
# PR label
d.add(String(cx+1, baseline - 14, f"PR={pr*5}ms",
fillColor=TEAL, fontSize=5.5, fontName="Helvetica-Bold"))
# 4th P wave - no QRS (dropped)
d.add(String(134, baseline + 10, "P", fillColor=colors.HexColor("#AA3300"),
fontSize=8, fontName="Helvetica-Bold"))
draw_ecg_line(d, [(136, baseline), (139, baseline+7), (142, baseline)],
colors.HexColor("#AA3300"), lw=1.0)
d.add(String(140, baseline - 14, "BLOCKED",
fillColor=CRIMSON, fontSize=5.5, fontName="Helvetica-Bold"))
d.add(String(4, h - 8, "II", fillColor=NAVY, fontSize=7, fontName="Helvetica-Bold"))
return d
def make_wpw_ecg(w=160, h=55):
"""WPW - short PR, delta wave, wide QRS."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.40
for i in range(3):
cx = 20 + i * 50
pts = [
(cx - 18, baseline),
(cx - 14, baseline + 5), # P
(cx - 10, baseline + 2), # short PR
# delta wave (slow slur)
(cx - 7, baseline),
(cx - 2, baseline + 8), # delta wave slur
(cx, baseline + 22), # R peak
(cx + 4, baseline - 4), # S
(cx + 8, baseline),
(cx + 12, baseline + 5), # T
(cx + 16, baseline),
]
draw_ecg_line(d, pts, ECG_RED, lw=1.3)
# delta wave annotation
if i == 1:
d.add(String(cx - 8, baseline - 14, "δ wave",
fillColor=GOLD, fontSize=6, fontName="Helvetica-Bold"))
d.add(Line(cx - 7, baseline - 1, cx - 1, baseline + 7,
strokeColor=GOLD, strokeWidth=0.8))
d.add(String(4, h - 8, "I", fillColor=NAVY, fontSize=7, fontName="Helvetica-Bold"))
return d
def make_lvh_ecg(w=160, h=65):
"""LVH - tall R in V5, deep S in V1, strain pattern."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.55
# V1 - deep S
cx1 = 28
pts_v1 = [
(cx1 - 12, baseline),
(cx1 - 8, baseline + 4), # small r
(cx1 - 4, baseline),
(cx1, baseline - 22), # deep S
(cx1 + 4, baseline),
(cx1 + 9, baseline - 3), # T negative
(cx1 + 14, baseline),
]
draw_ecg_line(d, pts_v1, ECG_RED, lw=1.3)
d.add(String(cx1 - 14, 6, "V1", fillColor=NAVY, fontSize=7, fontName="Helvetica-Bold"))
# V5 - tall R, strain
cx5 = 110
pts_v5 = [
(cx5 - 12, baseline),
(cx5 - 8, baseline + 4),
(cx5 - 4, baseline),
(cx5 - 2, baseline - 2), # small Q
(cx5, baseline + 40), # TALL R
(cx5 + 3, baseline - 3),
(cx5 + 6, baseline - 5), # ST depression (strain)
(cx5 + 12, baseline - 12), # T inversion (strain)
(cx5 + 16, baseline - 3),
(cx5 + 20, baseline),
]
draw_ecg_line(d, pts_v5, ECG_RED, lw=1.3)
d.add(String(cx5 - 8, 6, "V5", fillColor=NAVY, fontSize=7, fontName="Helvetica-Bold"))
# measurement arrows
d.add(Line(cx5 + 1, baseline, cx5 + 1, baseline + 40,
strokeColor=TEAL, strokeWidth=0.7, strokeDashArray=[2, 2]))
d.add(String(cx5 + 4, baseline + 18, "R>25mm",
fillColor=TEAL, fontSize=5.5, fontName="Helvetica-Bold"))
# strain label
d.add(String(cx5 + 8, baseline - 16, "Strain",
fillColor=CRIMSON, fontSize=5.5, fontName="Helvetica-Bold"))
return d
def make_lbbb_ecg(w=160, h=55):
"""LBBB - broad monophasic R in V6, QS in V1."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.48
# V1 - QS pattern
cx1 = 28
pts_v1 = [
(cx1 - 10, baseline),
(cx1, baseline - 20), # deep QS
(cx1 + 10, baseline),
(cx1 + 14, baseline + 5), # T positive (discordant)
(cx1 + 18, baseline),
]
draw_ecg_line(d, pts_v1, ECG_RED, lw=1.3)
d.add(String(cx1 - 14, 6, "V1\n(QS)", fillColor=NAVY, fontSize=6.5,
fontName="Helvetica-Bold"))
# V6 - broad positive R
cx6 = 116
pts_v6 = [
(cx6 - 14, baseline),
# broad, notched R (no Q)
(cx6 - 8, baseline + 2),
(cx6 - 4, baseline + 14),
(cx6, baseline + 22), # R peak notched
(cx6 + 4, baseline + 18),
(cx6 + 8, baseline + 22), # second notch (M-pattern)
(cx6 + 12, baseline + 2),
(cx6 + 14, baseline),
(cx6 + 16, baseline - 6), # ST depression
(cx6 + 20, baseline - 10), # T inversion
(cx6 + 24, baseline),
]
draw_ecg_line(d, pts_v6, ECG_RED, lw=1.3)
d.add(String(cx6 - 8, 6, "V6\n(RR')", fillColor=NAVY, fontSize=6.5,
fontName="Helvetica-Bold"))
# QRS width annotation
d.add(Line(cx6 - 14, baseline - 16, cx6 + 14, baseline - 16,
strokeColor=TEAL, strokeWidth=0.7))
d.add(String(cx6 - 8, baseline - 24, "QRS ≥ 120ms",
fillColor=TEAL, fontSize=5.5, fontName="Helvetica-Bold"))
return d
def make_rbbb_ecg(w=160, h=55):
"""RBBB - rSR' in V1, wide S in V6."""
d = Drawing(w, h)
ecg_grid(d, 0, 0, w, h, rows=2, cols=4)
baseline = h * 0.45
# V1 - rSR' pattern
cx1 = 32
pts_v1 = [
(cx1 - 14, baseline),
(cx1 - 8, baseline + 10), # small r
(cx1 - 4, baseline + 2),
(cx1, baseline - 4), # S
(cx1 + 4, baseline + 18), # R' tall
(cx1 + 10, baseline + 2),
(cx1 + 14, baseline - 6), # T inversion
(cx1 + 18, baseline),
]
draw_ecg_line(d, pts_v1, ECG_RED, lw=1.3)
d.add(String(cx1 - 16, 6, "V1\n(rSR')", fillColor=NAVY, fontSize=6.5,
fontName="Helvetica-Bold"))
# V6 - wide S wave
cx6 = 118
pts_v6 = [
(cx6 - 14, baseline),
(cx6 - 10, baseline + 4), # q
(cx6 - 7, baseline + 2),
(cx6 - 4, baseline),
(cx6, baseline + 18), # R
(cx6 + 4, baseline),
(cx6 + 8, baseline - 12), # wide, deep S
(cx6 + 14, baseline - 4),
(cx6 + 18, baseline),
(cx6 + 20, baseline + 5), # T
(cx6 + 24, baseline),
]
draw_ecg_line(d, pts_v6, ECG_RED, lw=1.3)
d.add(String(cx6 - 6, 6, "V6\n(qRS)", fillColor=NAVY, fontSize=6.5,
fontName="Helvetica-Bold"))
d.add(String(cx6 + 4, baseline - 20, "Wide S",
fillColor=TEAL, fontSize=5.5, fontName="Helvetica-Bold"))
return d
# ── ECG Drawing Flowable ───────────────────────────────────────────────────────
from reportlab.platypus import Flowable as RLFlowable
class ECGDrawing(RLFlowable):
def __init__(self, drawing, width=None, height=None):
RLFlowable.__init__(self)
self.drawing = drawing
self.width = width or drawing.width
self.height = height or drawing.height
def wrap(self, aW, aH):
return (self.width, self.height)
def draw(self):
renderPDF.draw(self.drawing, self.canv, 0, 0)
# ── Section header ─────────────────────────────────────────────────────────────
def section_header(title, number, color=NAVY):
data = [[
Paragraph(f"<b>{number}</b>", ParagraphStyle("Num", fontName="Helvetica-Bold",
fontSize=14, textColor=GOLD, alignment=TA_CENTER)),
Paragraph(title, HDR_STYLE)
]]
t = Table(data, colWidths=[18*mm, None])
t.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), color),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING",(0,0),(-1,-1), 4),
("LEFTPADDING", (0,0), (0,-1), 3),
("RIGHTPADDING",(-1,0),(-1,-1), 6),
("ROUNDEDCORNERS", [3]),
]))
return t
def criteria_table(rows, col_widths=None):
"""Two-column criteria table: feature | value."""
styled_rows = []
for r in rows:
if len(r) == 1: # sub-header
styled_rows.append([
Paragraph(f"<b>{r[0]}</b>",
ParagraphStyle("SubHdr", fontName="Helvetica-Bold", fontSize=7.5,
textColor=NAVY, leading=10)),
Paragraph("", BODY_STYLE)
])
else:
styled_rows.append([
Paragraph(f"<b>{r[0]}</b>", BOLD_STYLE),
Paragraph(r[1], BODY_STYLE)
])
cw = col_widths or [45*mm, None]
t = Table(styled_rows, colWidths=cw, hAlign="LEFT")
t.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), LIGHT_GRAY),
("BACKGROUND", (0,0), (-1,0), LIGHT_BLUE),
("ROWBACKGROUNDS",(0,0),(-1,-1), [WHITE, LIGHT_GRAY]),
("GRID", (0,0), (-1,-1), 0.3, MID_GRAY),
("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),
]))
return t
def alert_box(text, bg=colors.HexColor("#FFF3CD"), border=GOLD):
"""Yellow alert/note box."""
p = Paragraph(f"⚠ {text}", ParagraphStyle("Alert", fontName="Helvetica-Bold",
fontSize=7, textColor=colors.HexColor("#7D5A00"), leading=10))
t = Table([[p]], colWidths=[None])
t.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), bg),
("BOX", (0,0),(-1,-1), 1.0, border),
("TOPPADDING", (0,0),(-1,-1), 4),
("BOTTOMPADDING",(0,0),(-1,-1), 4),
("LEFTPADDING", (0,0),(-1,-1), 6),
]))
return t
def critical_box(text):
"""Red critical alert."""
p = Paragraph(f"🚨 {text}", ParagraphStyle("Crit2", fontName="Helvetica-Bold",
fontSize=7, textColor=WHITE, leading=10))
t = Table([[p]], colWidths=[None])
t.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), CRIMSON),
("TOPPADDING", (0,0),(-1,-1), 4),
("BOTTOMPADDING",(0,0),(-1,-1), 4),
("LEFTPADDING", (0,0),(-1,-1), 6),
]))
return t
# ── Page canvas (header/footer) ────────────────────────────────────────────────
def on_page(canv, doc):
canv.saveState()
# top banner
canv.setFillColor(NAVY)
canv.rect(0, H - 28, W, 28, fill=1, stroke=0)
canv.setFillColor(WHITE)
canv.setFont("Helvetica-Bold", 10)
canv.drawString(14, H - 18, "ECG REFERENCE CARD")
canv.setFont("Helvetica", 8)
canv.setFillColor(colors.HexColor("#A0C4E0"))
canv.drawRightString(W - 14, H - 18, f"Page {doc.page} | Orris Medical Reference")
# footer
canv.setFillColor(NAVY)
canv.rect(0, 0, W, 18, fill=1, stroke=0)
canv.setFillColor(MID_GRAY)
canv.setFont("Helvetica-Oblique", 6.5)
canv.drawString(14, 6, "Sources: Harrison's 22E · Guyton & Hall · Ganong's · Tintinalli's EM")
canv.setFillColor(colors.HexColor("#A0C4E0"))
canv.drawRightString(W - 14, 6, "For educational use only — not a substitute for clinical judgment")
canv.restoreState()
def on_first_page(canv, doc):
on_page(canv, doc)
# ── Build document ─────────────────────────────────────────────────────────────
def build():
os.makedirs("/home/daytona/workspace/ecg-reference", exist_ok=True)
doc = SimpleDocTemplate(
OUTPUT, pagesize=A4,
leftMargin=14*mm, rightMargin=14*mm,
topMargin=34*mm, bottomMargin=24*mm,
title="ECG Reference Card",
author="Orris Medical",
subject="ECG Patterns & Diagnostic Criteria",
)
story = []
# ── Cover block ──────────────────────────────────────────────────────────
cover_data = [[
Paragraph("ECG Reference Card", TITLE_STYLE),
],[
Paragraph("Common Heart Diseases · Characteristic ECG Patterns · Diagnostic Criteria", SUB_STYLE),
],[
Paragraph("Harrison's 22E · Guyton & Hall · Ganong's · Tintinalli's EM", SUB_STYLE),
]]
cover = Table(cover_data, colWidths=[None])
cover.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), NAVY),
("TOPPADDING", (0,0),(-1,-1), 6),
("BOTTOMPADDING",(0,0),(-1,-1), 6),
("LEFTPADDING", (0,0),(-1,-1), 10),
("RIGHTPADDING", (0,0),(-1,-1), 10),
("ROUNDEDCORNERS", [4]),
]))
story.append(cover)
story.append(Spacer(1, 5*mm))
# ── Quick-Reference Summary Table ────────────────────────────────────────
qr_hdr = [
Paragraph("<b>Condition</b>", ParagraphStyle("QH", fontName="Helvetica-Bold",
fontSize=7.5, textColor=WHITE, alignment=TA_CENTER)),
Paragraph("<b>Key Feature</b>", ParagraphStyle("QH", fontName="Helvetica-Bold",
fontSize=7.5, textColor=WHITE, alignment=TA_CENTER)),
Paragraph("<b>Rate</b>", ParagraphStyle("QH", fontName="Helvetica-Bold",
fontSize=7.5, textColor=WHITE, alignment=TA_CENTER)),
Paragraph("<b>Rhythm</b>", ParagraphStyle("QH", fontName="Helvetica-Bold",
fontSize=7.5, textColor=WHITE, alignment=TA_CENTER)),
Paragraph("<b>P Wave</b>", ParagraphStyle("QH", fontName="Helvetica-Bold",
fontSize=7.5, textColor=WHITE, alignment=TA_CENTER)),
Paragraph("<b>QRS</b>", ParagraphStyle("QH", fontName="Helvetica-Bold",
fontSize=7.5, textColor=WHITE, alignment=TA_CENTER)),
]
qr_rows = [
["STEMI", "ST elevation + Q waves", "Variable", "Regular", "Normal", "Normal/Wide"],
["NSTEMI", "ST depression / T inversions", "Variable", "Regular", "Normal", "Normal"],
["Atrial Fibrillation","No P waves; f-waves", "90–170 bpm", "Irreg. irregular", "Absent", "Normal"],
["Atrial Flutter", "Sawtooth F-waves 250–350/min", "75–150 bpm", "Regular (fixed ratio)","Flutter waves", "Normal"],
["Ventricular Fib.", "Chaotic oscillations", "Indeterm.", "No rhythm", "None", "None"],
["Ventricular Tach.", "Wide regular QRS ≥3 beats", "100–250 bpm", "Regular", "Dissociated/absent","Wide ≥120ms"],
["1° AV Block", "PR > 200 ms", "Normal", "Regular", "Normal", "Normal"],
["2° AV Block (I)", "Progressive PR → dropped QRS", "Slow", "Irregular", "Normal", "Normal"],
["2° AV Block (II)", "Fixed PR → sudden dropped QRS", "Slow", "Irregular", "Normal", "Wide (usually)"],
["3° AV Block", "P-QRS dissociation", "Vent: 20–60", "Indep. regular", "Normal (indep.)", "Wide or narrow"],
["WPW Syndrome", "Short PR + delta wave", "Normal", "Regular (sinus)", "Normal", "Wide (≥120ms)"],
["LVH", "Voltage criteria + strain", "Normal", "Regular", "Bifid (LAE)", "Tall"],
["LBBB", "QS V1; broad R V6; no Q I,V6", "Normal", "Regular", "Normal", "≥120ms"],
["RBBB", "rSR' V1; wide S I, V6", "Normal", "Regular", "Normal", "≥120ms"],
]
cell_style = ParagraphStyle("Cell", fontName="Helvetica", fontSize=6.8,
textColor=DARK_GRAY, leading=9)
bold_cell = ParagraphStyle("BCell", fontName="Helvetica-Bold", fontSize=6.8,
textColor=NAVY, leading=9)
table_data = [qr_hdr]
for r in qr_rows:
table_data.append([
Paragraph(r[0], bold_cell),
Paragraph(r[1], cell_style),
Paragraph(r[2], cell_style),
Paragraph(r[3], cell_style),
Paragraph(r[4], cell_style),
Paragraph(r[5], cell_style),
])
cw = [32*mm, 44*mm, 22*mm, 28*mm, 22*mm, 22*mm]
qr_table = Table(table_data, colWidths=cw, hAlign="LEFT")
qr_table.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,0), NAVY),
("ROWBACKGROUNDS",(0,1),(-1,-1),[WHITE, LIGHT_BLUE]),
("GRID", (0,0),(-1,-1), 0.3, MID_GRAY),
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
("TOPPADDING", (0,0),(-1,-1), 2.5),
("BOTTOMPADDING",(0,0),(-1,-1), 2.5),
("LEFTPADDING", (0,0),(-1,-1), 3),
("RIGHTPADDING", (0,0),(-1,-1), 3),
]))
story.append(Paragraph("<b>QUICK-REFERENCE SUMMARY</b>",
ParagraphStyle("QRTitle", fontName="Helvetica-Bold",
fontSize=9, textColor=NAVY, spaceBefore=2, spaceAfter=3)))
story.append(qr_table)
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════
# Helper: build one condition block
# ════════════════════════════════════════════════════════════════════════════
def condition_block(num, title, drawing_fn, criteria_rows, notes=None,
critical=None, alert=None, ecg_caption=""):
elems = []
elems.append(KeepTogether([
section_header(title, num, color=NAVY),
Spacer(1, 2*mm),
]))
# ECG drawing (left) + criteria (right)
d = drawing_fn()
ecg_cell = [ECGDrawing(d), Spacer(1, 1*mm), Paragraph(ecg_caption, CAPTION)]
crit_cell = [criteria_table(criteria_rows)]
if notes:
crit_cell.append(Spacer(1, 1.5*mm))
crit_cell.append(Paragraph(notes, NOTE_STYLE))
side_table = Table(
[[ecg_cell, crit_cell]],
colWidths=[d.width + 4, None],
)
side_table.setStyle(TableStyle([
("VALIGN", (0,0),(-1,-1), "TOP"),
("LEFTPADDING", (0,0),(-1,-1), 0),
("RIGHTPADDING", (0,0),(-1,-1), 4),
("TOPPADDING", (0,0),(-1,-1), 0),
("BOTTOMPADDING",(0,0),(-1,-1), 0),
]))
elems.append(side_table)
if critical:
elems.append(Spacer(1, 1.5*mm))
elems.append(critical_box(critical))
if alert:
elems.append(Spacer(1, 1.5*mm))
elems.append(alert_box(alert))
elems.append(Spacer(1, 4*mm))
elems.append(HRFlowable(width="100%", thickness=0.5, color=MID_GRAY))
elems.append(Spacer(1, 3*mm))
return elems
# ── 1. STEMI ──────────────────────────────────────────────────────────────
story += condition_block(
"1", "Acute Myocardial Infarction — STEMI",
make_stemi_ecg,
[
["Phase / Feature", "ECG Finding"],
["Hyperacute (min)", "Tall, peaked T waves (hyperacute T)"],
["Early acute (hours)", "ST elevation ≥1 mm (≥2 mm V1-V4) in ≥2 contiguous leads"],
["Evolving (hours-days)","T-wave inversions; pathological Q waves develop"],
["Established", "Persistent Q waves; ST normalises"],
["Reciprocal changes", "ST depression in opposite leads (mirror image)"],
["Localisation", ""],
["Anterior (LAD)", "ST ↑ V1–V6, I, aVL"],
["Inferior (RCA)", "ST ↑ II, III, aVF; reciprocal ↓ I, aVL"],
["Posterior (LCx)", "ST ↓ V1–V3 + dominant R (ST ↑ equivalent)"],
["RV infarct", "ST ↑ V3R, V4R; seen with inferior MI"],
],
notes="Pathological Q wave: duration ≥40 ms or depth ≥25% of R-wave height.",
critical="STEMI is a time-critical emergency. Target door-to-balloon ≤90 min.",
ecg_caption="STEMI: ST elevation with early T-wave changes (V4)",
col_widths=[38*mm, None]
)
# ── 2. NSTEMI ─────────────────────────────────────────────────────────────
story += condition_block(
"2", "Acute Coronary Syndrome — NSTEMI / UA",
make_normal_ecg, # shows baseline for comparison
[
["Feature", "ECG Finding"],
["ST depression", "Horizontal or downsloping ≥0.5 mm in ≥2 contiguous leads"],
["T-wave inversion", "Symmetric, ≥1 mm, in leads with prominent R waves"],
["Transient ST changes","Changing with symptoms (dynamic ischemia)"],
["Wellens' syndrome", "Biphasic or deep symmetric T inversions V2–V3 (LAD stenosis)"],
["De Winter T-waves", "ST depression + tall peaked T (LAD occlusion equivalent)"],
["No ST elevation", "No pathological Q waves required for NSTEMI"],
["Troponin", "Elevated (distinguishes NSTEMI from unstable angina)"],
],
notes="10–15% of NSTEMI present with a normal ECG — serial ECGs and troponins are essential.",
alert="Normal ECG does not exclude ACS — obtain serial ECGs and troponins.",
ecg_caption="NSTEMI: changes may be subtle; compare with baseline ECG",
)
# ── 3. Atrial Fibrillation ────────────────────────────────────────────────
story += condition_block(
"3", "Atrial Fibrillation",
make_afib_ecg,
[
["Feature", "ECG Finding"],
["P waves", "Absent — replaced by fibrillatory f-waves (350–600/min)"],
["RR intervals", "Irregularly irregular — the hallmark"],
["f-wave rate", "350–600 atrial impulses per minute"],
["QRS duration", "Normal (unless aberrant conduction or pre-excitation)"],
["Ventricular rate", "90–170 bpm (uncontrolled); 60–100 bpm (rate-controlled)"],
["Baseline", "Fine, irregular low-voltage oscillations"],
],
notes="Causes: hypertension, valvular disease, thyrotoxicosis, alcohol, HF, lone AF.",
alert="Assess CHA₂DS₂-VASc score for anticoagulation in all patients with AF.",
ecg_caption="AF: irregular QRS, absent P waves, fibrillatory baseline",
)
# ── 4. Atrial Flutter ─────────────────────────────────────────────────────
story += condition_block(
"4", "Atrial Flutter",
make_aflutter_ecg,
[
["Feature", "ECG Finding"],
["Flutter (F) waves", "Regular sawtooth waves, 250–350/min; no isoelectric baseline"],
["Best seen in", "Leads II, III, aVF, V1"],
["AV conduction ratio", "Typically 2:1 → ventricular rate ~150 bpm"],
["Variable ratios", "3:1, 4:1, or variable block also possible"],
["QRS complex", "Normal morphology (unless aberrant conduction)"],
["P waves", "Flutter waves replace P waves"],
["Regularity", "Ventricular rhythm regular or regularly irregular"],
],
notes="Ventricular rate of exactly 150 bpm should always raise suspicion for atrial flutter with 2:1 block.",
ecg_caption="Atrial flutter: sawtooth F-waves with 2:1 AV block",
)
story.append(PageBreak())
# ── 5. Ventricular Fibrillation ───────────────────────────────────────────
story += condition_block(
"5", "Ventricular Fibrillation",
make_vfib_ecg,
[
["Feature", "ECG Finding"],
["Waveform", "Completely chaotic, irregular oscillations — no identifiable complexes"],
["Coarse VF (early)", "High-amplitude irregular waves ~0.5 mV; first 30 sec"],
["Fine VF (late)", "Low-amplitude waves ~0.2 mV after 20–30 sec"],
["Rate", "Indeterminate — 300–500 chaotic ventricular activations/min"],
["P waves", "Absent"],
["QRS/T", "Not identifiable"],
["Haemodynamics", "Cardiac output = zero; immediately fatal"],
],
critical="Initiate CPR immediately. Defibrillate without delay. Epinephrine 1 mg IV q3–5 min.",
ecg_caption="VF: chaotic oscillations — no organised waveforms",
)
# ── 6. Ventricular Tachycardia ────────────────────────────────────────────
story += condition_block(
"6", "Ventricular Tachycardia (VT)",
make_vtach_ecg,
[
["Feature", "ECG Finding"],
["QRS duration", "≥120 ms (wide complex)"],
["Rate", "100–250 bpm (typically 140–200)"],
["Rhythm", "Regular or slightly irregular"],
["P waves", "Often dissociated from QRS (AV dissociation)"],
["Fusion beats", "Pathognomonic — QRS hybrid of sinus + ectopic"],
["Capture beats", "Narrow QRS during VT (sinus captures ventricle)"],
["Concordance", "All precordial QRS same direction (positive or negative)"],
["Axis", "Extreme axis deviation (NW axis) favours VT"],
["Duration", "Sustained: ≥30 sec; Non-sustained: <30 sec"],
],
notes="Brugada criteria, Vereckei algorithm, or aVR lead algorithm help distinguish VT from SVT with aberrancy. When in doubt, treat as VT.",
critical="Pulseless VT: defibrillate immediately. Haemodynamically unstable VT: synchronised cardioversion.",
ecg_caption="VT: wide, regular QRS complexes at rapid rate",
)
# ── 7. First-Degree AV Block ──────────────────────────────────────────────
story += condition_block(
"7", "First-Degree Atrioventricular Block",
make_first_degree_block_ecg,
[
["Feature", "ECG Finding"],
["PR interval", "> 200 ms (0.20 s) — defining criterion"],
["Conduction", "Every P wave is conducted to ventricles (1:1)"],
["P wave morphology", "Normal"],
["QRS complex", "Normal (unless co-existing bundle branch block)"],
["Rhythm", "Regular"],
["Site of delay", "Usually AV node; occasionally His-Purkinje"],
],
notes="Causes: vagal tone (athletes), inferior MI, myocarditis, digitalis, hyperkalaemia, beta-blockers, calcium channel blockers.",
alert="First-degree block alone rarely requires treatment but warrants monitoring for progression.",
ecg_caption="1° AV block: PR = 0.38 s (normal ≤ 0.20 s)",
)
# ── 8. Second-Degree AV Block ─────────────────────────────────────────────
story += condition_block(
"8", "Second-Degree Atrioventricular Block",
make_wenckebach_ecg,
[
["Type", "Distinguishing Feature"],
["Mobitz I (Wenckebach)","Progressive PR lengthening → dropped QRS; AV node level"],
["PR change in Mobitz I","Each PR longer than the last; RR shortens before drop"],
["Mobitz II", "Sudden dropped QRS without prior PR change; infranodal"],
["QRS in Mobitz II", "Usually wide (bundle branch block pattern)"],
["2:1 block", "Every other P blocked; cannot classify without other beats"],
["Risk of Mobitz II", "Higher risk of progression to complete block"],
["Wenckebach", "Usually benign; pacemaker rarely needed"],
],
notes="Mobitz II with wide QRS = high risk of complete heart block → pacemaker often needed.",
alert="Mobitz II and high-degree block require urgent cardiology referral for pacemaker assessment.",
ecg_caption="Wenckebach: PR progressively lengthens until 4th P wave is blocked",
)
story.append(PageBreak())
# ── 9. Complete (Third-Degree) AV Block ──────────────────────────────────
story += condition_block(
"9", "Third-Degree (Complete) AV Block",
make_complete_block_ecg,
[
["Feature", "ECG Finding"],
["P waves", "Present, regular, at normal atrial rate (60–100 bpm)"],
["QRS complexes", "Present, regular, at escape rate (20–60 bpm)"],
["P-QRS relationship", "Completely dissociated — no fixed PR interval"],
["QRS morphology", "Narrow = junctional escape (40–60 bpm); Wide = ventricular escape (20–40 bpm)"],
["P rate > QRS rate", "More P waves than QRS complexes"],
["Symptoms", "Syncope (Stokes-Adams attacks), presyncope, fatigue"],
["Causes", "Inferior MI, Lyme disease, myocarditis, surgical trauma, drugs"],
],
critical="Complete heart block = medical emergency. Atropine (if narrow QRS). Transcutaneous pacing immediately. Cardiology consult for transvenous pacemaker.",
ecg_caption="3° block: atrial rate 107/min, ventricular escape 43/min — fully dissociated",
)
# ── 10. WPW Syndrome ─────────────────────────────────────────────────────
story += condition_block(
"10", "Wolff-Parkinson-White (WPW) Syndrome",
make_wpw_ecg,
[
["Feature (sinus rhythm)", "ECG Finding"],
["PR interval", "Short: < 120 ms (0.12 s) — AV node bypassed"],
["Delta wave", "Slurred, slow upstroke at start of QRS — pre-excitation"],
["QRS duration", "Wide: ≥ 120 ms (fusion of AP + normal conduction)"],
["ST-T changes", "Discordant (opposite to delta wave/QRS direction)"],
["Tachyarrhythmias", ""],
["Orthodromic AVRT", "Narrow QRS tachycardia (most common, 70%)"],
["Antidromic AVRT", "Wide QRS tachycardia (mimics VT)"],
["AF with WPW", "Irregular wide-complex tachycardia — life-threatening"],
],
notes="Localisation: negative delta in lead III → left posterior AP. Negative delta V1 → left-sided AP.",
critical="AF + WPW: AVOID adenosine, beta-blockers, CCBs, digoxin — may cause VF. Use procainamide or cardioversion.",
ecg_caption="WPW: short PR, delta (δ) wave slur, wide QRS — dual pathway conduction",
)
# ── 11. LVH ───────────────────────────────────────────────────────────────
story += condition_block(
"11", "Left Ventricular Hypertrophy (LVH)",
make_lvh_ecg,
[
["Voltage Criteria", "Threshold"],
["Sokolow-Lyon", "S (V1) + R (V5 or V6) ≥ 35 mm"],
["Cornell (men)", "R (aVL) + S (V3) > 28 mm"],
["Cornell (women)", "R (aVL) + S (V3) > 20 mm"],
["RaVL", "> 20 mm (women) or > 28 mm (men)"],
["Associated findings", ""],
["Strain pattern", "ST depression + asymmetric T-inversion in I, aVL, V5–V6"],
["Left axis deviation", "QRS axis < −30°"],
["LAE", "Bifid P wave ≥120ms in II; biphasic P in V1"],
["QRS widening", "May progress to LBBB"],
],
notes="Sensitivity of voltage criteria is low (~50%) in older adults, obese patients, and those with RBBB. Echocardiography provides definitive diagnosis.",
alert="Prominent voltage in young athletes may be a normal variant — clinical context is essential.",
ecg_caption="LVH: deep S in V1, very tall R in V5, lateral strain pattern",
)
story.append(PageBreak())
# ── 12. LBBB ─────────────────────────────────────────────────────────────
story += condition_block(
"12", "Left Bundle Branch Block (LBBB)",
make_lbbb_ecg,
[
["Feature", "ECG Finding"],
["QRS duration", "≥ 120 ms (complete LBBB)"],
["Lead V1", "Broad, deep QS (or rS) complex"],
["Leads I, aVL, V5–V6", "Broad, monophasic R wave (no septal Q)"],
["Septal Q waves", "Absent in I, aVL, V5, V6 (septal activation reversed)"],
["ST-T changes", "Discordant to QRS (ST ↑ where QRS negative; ST ↓ where QRS positive)"],
["Sgarbossa criteria", "Concordant ST ≥1 mm (score 5) or ST ↑ ≥5 mm (score 2) in new LBBB suggest MI"],
["Axis", "Left axis deviation common"],
["Causes", "Hypertension, CAD, dilated cardiomyopathy, aortic stenosis"],
],
critical="New LBBB + chest pain = STEMI equivalent until proven otherwise. Activate cath lab.",
ecg_caption="LBBB: QS in V1, broad notched R in V6, QRS ≥120ms",
)
# ── 13. RBBB ─────────────────────────────────────────────────────────────
story += condition_block(
"13", "Right Bundle Branch Block (RBBB)",
make_rbbb_ecg,
[
["Feature", "ECG Finding"],
["QRS duration", "≥ 120 ms (complete RBBB)"],
["Lead V1", "rSR' pattern ('M' shape or rabbit ears); terminal R' broad"],
["Leads I, V5–V6", "Wide, slurred S wave (terminal activation rightward)"],
["ST-T changes", "T-wave inversion V1–V3 (secondary to block; not ischemic)"],
["Axis", "Right axis deviation common"],
["Causes (new onset)", "PE, right heart strain, anterior MI, myocarditis"],
["Isolated RBBB", "Often incidental; may be normal variant"],
["S₁Q₃T₃ + RBBB", "Suggests acute right heart strain / PE"],
],
notes="Right bundle branch block is more commonly a benign finding than LBBB. New RBBB warrants investigation for PE or anterior MI.",
ecg_caption="RBBB: rSR' in V1, wide terminal S in V6, QRS ≥120ms",
)
# ── Electrolyte Changes reference ─────────────────────────────────────────
story.append(Spacer(1, 2*mm))
story.append(section_header("⚡ ELECTROLYTE & ION EFFECTS ON ECG", "REF", color=TEAL))
story.append(Spacer(1, 2*mm))
elec_data = [
[
Paragraph("<b>Hyperkalaemia</b>", BOLD_STYLE),
Paragraph("Tall peaked T waves (early) → PR prolongation → wide QRS → sine wave → asystole", BODY_STYLE),
Paragraph("<b>Hypokalaemia</b>", BOLD_STYLE),
Paragraph("Flat/inverted T waves, prominent U waves, ST depression, apparent long QT", BODY_STYLE),
],
[
Paragraph("<b>Hypercalcaemia</b>", BOLD_STYLE),
Paragraph("Short QT interval, short ST segment, J waves (Osborn waves)", BODY_STYLE),
Paragraph("<b>Hypocalcaemia</b>", BOLD_STYLE),
Paragraph("Prolonged QT interval (prolonged ST segment), unchanged T wave", BODY_STYLE),
],
[
Paragraph("<b>Long QT Syndrome</b>", BOLD_STYLE),
Paragraph("QTc > 450 ms (men) / 460 ms (women); risk of torsades de pointes", BODY_STYLE),
Paragraph("<b>Digoxin effect</b>", BOLD_STYLE),
Paragraph("Scooped ST depression ('reverse tick'), short QT, PR prolongation", BODY_STYLE),
],
]
elec_table = Table(elec_data, colWidths=[35*mm, 60*mm, 35*mm, None])
elec_table.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), LIGHT_GRAY),
("ROWBACKGROUNDS",(0,0),(-1,-1), [WHITE, LIGHT_BLUE, WHITE]),
("GRID", (0,0),(-1,-1), 0.3, MID_GRAY),
("VALIGN", (0,0),(-1,-1), "TOP"),
("TOPPADDING", (0,0),(-1,-1), 3),
("BOTTOMPADDING", (0,0),(-1,-1), 3),
("LEFTPADDING", (0,0),(-1,-1), 4),
]))
story.append(elec_table)
story.append(Spacer(1, 4*mm))
# ── ECG Normal Values ─────────────────────────────────────────────────────
story.append(section_header("📏 NORMAL ECG INTERVALS & VALUES", "NV", color=colors.HexColor("#2E7D32")))
story.append(Spacer(1, 2*mm))
nv_data = [
["PR interval", "120–200 ms (3–5 small squares)"],
["QRS duration", "60–100 ms (<120 ms; 3 small squares)"],
["QT interval", "350–440 ms (rate-dependent)"],
["QTc (corrected)", "Men: ≤ 450 ms | Women: ≤ 460 ms (Bazett formula)"],
["P-wave duration", "< 120 ms; amplitude < 2.5 mm in limb leads"],
["P-wave axis", "0° to +75° (upright in I, II; inverted in aVR)"],
["QRS axis", "−30° to +90° (normal); −30° to −90° = LAD; >+90° = RAD"],
["Heart rate", "60–100 bpm (sinus rhythm)"],
["Paper speed", "25 mm/sec | 1 small square = 40 ms, 1 large = 200 ms"],
["Amplitude", "Standard: 10 mm/mV | 1 small square = 0.1 mV"],
]
nv_table_data = []
for r in nv_data:
nv_table_data.append([
Paragraph(f"<b>{r[0]}</b>", BOLD_STYLE),
Paragraph(r[1], BODY_STYLE),
])
nv_table = Table(nv_table_data, colWidths=[48*mm, None])
nv_table.setStyle(TableStyle([
("ROWBACKGROUNDS",(0,0),(-1,-1), [WHITE, LIGHT_BLUE]),
("GRID", (0,0),(-1,-1), 0.3, MID_GRAY),
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
("TOPPADDING", (0,0),(-1,-1), 3),
("BOTTOMPADDING", (0,0),(-1,-1), 3),
("LEFTPADDING", (0,0),(-1,-1), 4),
]))
story.append(nv_table)
story.append(Spacer(1, 4*mm))
# ── STEMI Territory localisation table ────────────────────────────────────
story.append(section_header("🗺 STEMI TERRITORY LOCALISATION", "LOC",
color=colors.HexColor("#6A1515")))
story.append(Spacer(1, 2*mm))
loc_data = [
[Paragraph("<b>Territory</b>", ParagraphStyle("LH",fontName="Helvetica-Bold",fontSize=7.5,textColor=WHITE)),
Paragraph("<b>ST Elevation Leads</b>", ParagraphStyle("LH",fontName="Helvetica-Bold",fontSize=7.5,textColor=WHITE)),
Paragraph("<b>Reciprocal ST Depression</b>", ParagraphStyle("LH",fontName="Helvetica-Bold",fontSize=7.5,textColor=WHITE)),
Paragraph("<b>Artery (usually)</b>", ParagraphStyle("LH",fontName="Helvetica-Bold",fontSize=7.5,textColor=WHITE))],
["Anterior", "V1–V4", "II, III, aVF", "LAD"],
["Anterolateral", "V4–V6, I, aVL", "II, III, aVF", "LAD (diagonal) or LCx"],
["Lateral", "I, aVL, V5–V6", "II, III, aVF", "LCx or diagonal"],
["Inferior", "II, III, aVF", "I, aVL", "RCA (80%) or LCx"],
["Posterior", "V7–V9 (direct); ST ↓ V1–V3", "None", "RCA or LCx"],
["RV", "V3R, V4R", "None", "Proximal RCA"],
["Septal", "V1–V2", "Variable", "Septal perforators (LAD)"],
]
loc_styled = []
for i, r in enumerate(loc_data):
if i == 0:
loc_styled.append(r)
else:
loc_styled.append([Paragraph(r[j], SMALL_STYLE) for j in range(4)])
loc_table = Table(loc_styled, colWidths=[30*mm, 46*mm, 46*mm, None])
loc_table.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,0), colors.HexColor("#6A1515")),
("ROWBACKGROUNDS",(0,1),(-1,-1), [WHITE, LIGHT_GRAY]),
("GRID", (0,0),(-1,-1), 0.3, MID_GRAY),
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
("TOPPADDING", (0,0),(-1,-1), 2.5),
("BOTTOMPADDING", (0,0),(-1,-1), 2.5),
("LEFTPADDING", (0,0),(-1,-1), 4),
]))
story.append(loc_table)
# ── Build ──────────────────────────────────────────────────────────────────
doc.build(story, onFirstPage=on_first_page, onLaterPages=on_page)
print(f"PDF generated: {OUTPUT}")
if __name__ == "__main__":
build()
cd /home/daytona/workspace/ecg-reference && python build_ecg_pdf.py 2>&1
~/ecg-reference/build_ecg_pdf.py
Successfully replaced 1 instance(s) of the string in '/home/daytona/workspace/ecg-reference/build_ecg_pdf.py'
~/ecg-reference/build_ecg_pdf.py
Successfully replaced 1 instance(s) of the string in '/home/daytona/workspace/ecg-reference/build_ecg_pdf.py'
cd /home/daytona/workspace/ecg-reference && python build_ecg_pdf.py 2>&1
ls -lh /home/daytona/workspace/ecg-reference/ECG_Reference_Card.pdf
ECG Reference Card
PDF Document · PDF