~/cardiac-cycle/generate_pdf.py
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm, mm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether, PageBreak
)
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
from reportlab.platypus import Flowable
import urllib.request
import io
# ── Colour Palette ──────────────────────────────────────────────────────────
DEEP_RED = colors.HexColor("#C0392B")
MED_RED = colors.HexColor("#E74C3C")
LIGHT_RED = colors.HexColor("#FADBD8")
DARK_BLUE = colors.HexColor("#1A3A5C")
MED_BLUE = colors.HexColor("#2980B9")
LIGHT_BLUE = colors.HexColor("#D6EAF8")
DARK_GREY = colors.HexColor("#2C3E50")
MED_GREY = colors.HexColor("#7F8C8D")
LIGHT_GREY = colors.HexColor("#F2F3F4")
YELLOW_BG = colors.HexColor("#FEF9E7")
GREEN_BG = colors.HexColor("#EAFAF1")
GREEN_DARK = colors.HexColor("#1E8449")
ORANGE = colors.HexColor("#E67E22")
ORANGE_BG = colors.HexColor("#FEF5E7")
WHITE = colors.white
PAGE_W, PAGE_H = A4
MARGIN = 1.8 * cm
# ── Styles ───────────────────────────────────────────────────────────────────
base_styles = getSampleStyleSheet()
def make_style(name, parent="Normal", **kwargs):
return ParagraphStyle(name, parent=base_styles[parent], **kwargs)
styles = {
"cover_title": make_style("cover_title", fontSize=34, leading=42,
textColor=WHITE, alignment=TA_CENTER, fontName="Helvetica-Bold"),
"cover_sub": make_style("cover_sub", fontSize=16, leading=22,
textColor=colors.HexColor("#FADBD8"), alignment=TA_CENTER),
"cover_tag": make_style("cover_tag", fontSize=11, leading=16,
textColor=colors.HexColor("#AED6F1"), alignment=TA_CENTER),
"h1": make_style("h1", fontSize=18, leading=24, textColor=WHITE,
fontName="Helvetica-Bold", alignment=TA_LEFT,
spaceAfter=4, spaceBefore=8),
"h2": make_style("h2", fontSize=13, leading=18, textColor=DARK_BLUE,
fontName="Helvetica-Bold", spaceBefore=10, spaceAfter=3),
"h3": make_style("h3", fontSize=11, leading=15, textColor=MED_RED,
fontName="Helvetica-Bold", spaceBefore=6, spaceAfter=2),
"body": make_style("body", fontSize=9.5, leading=14, textColor=DARK_GREY,
spaceAfter=4, alignment=TA_JUSTIFY),
"bullet": make_style("bullet", fontSize=9.5, leading=14, textColor=DARK_GREY,
leftIndent=14, bulletIndent=4, spaceAfter=2),
"note": make_style("note", fontSize=9, leading=13,
textColor=colors.HexColor("#6E2F00"),
leftIndent=8, spaceAfter=3),
"keynum": make_style("keynum", fontSize=10, leading=14, textColor=DARK_GREY,
fontName="Helvetica-Bold"),
"footer": make_style("footer", fontSize=7.5, leading=10,
textColor=MED_GREY, alignment=TA_CENTER),
"toc": make_style("toc", fontSize=10, leading=16, textColor=DARK_BLUE),
"phase_title": make_style("phase_title", fontSize=11, leading=15,
textColor=WHITE, fontName="Helvetica-Bold"),
"phase_body": make_style("phase_body", fontSize=9, leading=13,
textColor=DARK_GREY),
"qbox": make_style("qbox", fontSize=9.5, leading=14,
textColor=DARK_BLUE, fontName="Helvetica-Bold"),
"abox": make_style("abox", fontSize=9.5, leading=14, textColor=DARK_GREY),
"trap_title": make_style("trap_title", fontSize=10, leading=14,
textColor=colors.HexColor("#7B241C"),
fontName="Helvetica-Bold"),
"trap_body": make_style("trap_body", fontSize=9.5, leading=14,
textColor=DARK_GREY),
}
# ── Helpers ──────────────────────────────────────────────────────────────────
def section_header(title, color=DARK_BLUE):
"""Coloured full-width section header bar."""
data = [[Paragraph(title, styles["h1"])]]
tbl = Table(data, colWidths=[PAGE_W - 2*MARGIN])
tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), color),
("TOPPADDING", (0,0), (-1,-1), 7),
("BOTTOMPADDING", (0,0), (-1,-1), 7),
("LEFTPADDING", (0,0), (-1,-1), 10),
("RIGHTPADDING", (0,0), (-1,-1), 10),
("ROUNDEDCORNERS", [4]),
]))
return tbl
def coloured_table(data, col_widths, header_bg=DARK_BLUE, row_bg=LIGHT_BLUE,
alt_bg=WHITE, font_size=9):
tbl = Table(data, colWidths=col_widths, repeatRows=1)
n = len(data)
style_cmds = [
("BACKGROUND", (0, 0), (-1, 0), header_bg),
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), font_size),
("LEADING", (0, 0), (-1, -1), font_size + 4),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 7),
("RIGHTPADDING", (0, 0), (-1, -1), 7),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#BDC3C7")),
("ROWBACKGROUNDS",(0, 1), (-1, -1), [row_bg, alt_bg]),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
tbl.setStyle(TableStyle(style_cmds))
return tbl
def info_box(text, bg=YELLOW_BG, border=ORANGE, label="📌 Key Point"):
content = f"<b>{label}</b><br/>{text}"
data = [[Paragraph(content, styles["note"])]]
tbl = Table(data, colWidths=[PAGE_W - 2*MARGIN - 4])
tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), bg),
("LEFTBORDERPADDING", (0,0), (-1,-1), 6),
("BOX", (0,0), (-1,-1), 1.5, border),
("TOPPADDING", (0,0), (-1,-1), 7),
("BOTTOMPADDING", (0,0), (-1,-1), 7),
("LEFTPADDING", (0,0), (-1,-1), 10),
("RIGHTPADDING", (0,0), (-1,-1), 10),
]))
return tbl
def phase_block(number, name, ecg, valves, mechanism, heart_sound, color):
"""Coloured phase card."""
header_data = [[
Paragraph(f"Phase {number}", styles["phase_title"]),
Paragraph(name, styles["phase_title"]),
]]
header_tbl = Table(header_data, colWidths=[2.5*cm, PAGE_W - 2*MARGIN - 2.5*cm - 4])
header_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), color),
("TOPPADDING", (0,0), (-1,-1), 6),
("BOTTOMPADDING", (0,0), (-1,-1), 6),
("LEFTPADDING", (0,0), (-1,-1), 8),
("RIGHTPADDING", (0,0), (-1,-1), 8),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
]))
rows = [
["ECG Event", ecg],
["Valves", valves],
["Mechanism", mechanism],
["Heart Sound", heart_sound],
]
body_data = [[Paragraph(f"<b>{r[0]}</b>", styles["phase_body"]),
Paragraph(r[1], styles["phase_body"])] for r in rows]
body_tbl = Table(body_data, colWidths=[3.2*cm, PAGE_W - 2*MARGIN - 3.2*cm - 4])
body_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), colors.HexColor("#FDFEFE")),
("ROWBACKGROUNDS",(0,0), (-1,-1), [LIGHT_GREY, WHITE]),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING", (0,0), (-1,-1), 4),
("LEFTPADDING", (0,0), (-1,-1), 8),
("RIGHTPADDING", (0,0), (-1,-1), 8),
("GRID", (0,0), (-1,-1), 0.3, colors.HexColor("#D5D8DC")),
("VALIGN", (0,0), (-1,-1), "TOP"),
]))
wrapper = Table([[header_tbl], [body_tbl]],
colWidths=[PAGE_W - 2*MARGIN])
wrapper.setStyle(TableStyle([
("BOX", (0,0), (-1,-1), 1, color),
("TOPPADDING", (0,0), (-1,-1), 0),
("BOTTOMPADDING", (0,0), (-1,-1), 0),
("LEFTPADDING", (0,0), (-1,-1), 0),
("RIGHTPADDING", (0,0), (-1,-1), 0),
]))
return wrapper
# ── Page callbacks ────────────────────────────────────────────────────────────
def on_first_page(canvas, doc):
canvas.saveState()
# Full page background gradient simulation
canvas.setFillColor(DARK_BLUE)
canvas.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)
# Decorative circles
canvas.setFillColor(colors.HexColor("#1A5276"))
canvas.circle(PAGE_W * 0.85, PAGE_H * 0.85, 6*cm, fill=1, stroke=0)
canvas.setFillColor(colors.HexColor("#154360"))
canvas.circle(PAGE_W * 0.1, PAGE_H * 0.15, 4*cm, fill=1, stroke=0)
canvas.setFillColor(MED_RED)
canvas.circle(PAGE_W * 0.5, PAGE_H * 0.5, 0.4*cm, fill=1, stroke=0)
canvas.restoreState()
def on_later_pages(canvas, doc):
canvas.saveState()
# Top bar
canvas.setFillColor(DARK_BLUE)
canvas.rect(0, PAGE_H - 1.1*cm, PAGE_W, 1.1*cm, fill=1, stroke=0)
canvas.setFillColor(MED_RED)
canvas.rect(0, PAGE_H - 1.15*cm, PAGE_W, 0.12*cm, fill=1, stroke=0)
canvas.setFont("Helvetica-Bold", 8)
canvas.setFillColor(WHITE)
canvas.drawString(MARGIN, PAGE_H - 0.75*cm, "THE CARDIAC CYCLE - 1st Year Study Guide")
canvas.drawRightString(PAGE_W - MARGIN, PAGE_H - 0.75*cm, "Physiology")
# Bottom bar
canvas.setFillColor(LIGHT_GREY)
canvas.rect(0, 0, PAGE_W, 1*cm, fill=1, stroke=0)
canvas.setFillColor(MED_GREY)
canvas.setFont("Helvetica", 7.5)
canvas.drawCentredString(PAGE_W/2, 0.35*cm,
f"Page {doc.page} | Sources: Guyton & Hall | Costanzo | Ganong's Review of Medical Physiology")
canvas.restoreState()
# ── Document ──────────────────────────────────────────────────────────────────
OUTPUT = "/home/daytona/workspace/cardiac-cycle/Cardiac_Cycle_Study_Guide.pdf"
doc = SimpleDocTemplate(
OUTPUT, pagesize=A4,
leftMargin=MARGIN, rightMargin=MARGIN,
topMargin=1.4*cm, bottomMargin=1.4*cm,
title="The Cardiac Cycle - 1st Year Study Guide",
author="Orris Medical AI",
)
story = []
# ════════════════════════════════════════════════════════════════════════════════
# COVER PAGE
# ════════════════════════════════════════════════════════════════════════════════
story.append(Spacer(1, 4.5*cm))
story.append(Paragraph("THE CARDIAC CYCLE", styles["cover_title"]))
story.append(Spacer(1, 0.5*cm))
story.append(Paragraph("First Year Medical Student Study Guide", styles["cover_sub"]))
story.append(Spacer(1, 1.2*cm))
# Heart icon in text form
heart_text = "♥ Physiology | 1st Year MBBS | Exam-Ready Notes ♥"
story.append(Paragraph(heart_text, styles["cover_tag"]))
story.append(Spacer(1, 2.5*cm))
cover_info = [
["Topics Covered"],
["✓ Definition & Initiation"],
["✓ The 7 Phases of the Cardiac Cycle"],
["✓ Heart Sounds S1 S2 S3 S4"],
["✓ Key Numbers & Values"],
["✓ Pressure-Volume Loop"],
["✓ Jugular Venous Pulse Waves"],
["✓ Valve Opening & Closing"],
["✓ Exam Traps & Quick MCQ Answers"],
]
cover_tbl = Table(cover_info, colWidths=[10*cm])
cover_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), colors.HexColor("#1A5276")),
("BACKGROUND", (0,1), (-1,-1), colors.HexColor("#154360")),
("TEXTCOLOR", (0,0), (-1,-1), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTNAME", (0,1), (-1,-1), "Helvetica"),
("FONTSIZE", (0,0), (-1,-1), 11),
("LEADING", (0,0), (-1,-1), 18),
("TOPPADDING", (0,0), (-1,-1), 7),
("BOTTOMPADDING", (0,0), (-1,-1), 7),
("LEFTPADDING", (0,0), (-1,-1), 18),
("BOX", (0,0), (-1,-1), 1.5, MED_RED),
("LINEAFTER", (0,0), (0,-1), 0.5, colors.HexColor("#2E86C1")),
]))
# Centre the table
cover_wrapper = Table([[cover_tbl]], colWidths=[PAGE_W - 2*MARGIN])
cover_wrapper.setStyle(TableStyle([
("ALIGN", (0,0), (-1,-1), "CENTER"),
("TOPPADDING", (0,0), (-1,-1), 0),
("BOTTOMPADDING", (0,0), (-1,-1), 0),
("LEFTPADDING", (0,0), (-1,-1), 0),
("RIGHTPADDING", (0,0), (-1,-1), 0),
]))
story.append(cover_wrapper)
story.append(Spacer(1, 2*cm))
story.append(Paragraph(
"Sources: Guyton & Hall Medical Physiology | Costanzo Physiology 7th Ed | Ganong's Review 26th Ed",
styles["cover_tag"]
))
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 1 - INTRODUCTION
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("1. Introduction to the Cardiac Cycle", DARK_BLUE))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph(
"The <b>cardiac cycle</b> is the complete sequence of electrical and mechanical events that occurs "
"during a single heartbeat — from the beginning of one beat to the beginning of the next. "
"At a normal heart rate of <b>72 beats/min</b>, one complete cycle lasts approximately <b>0.83 seconds</b>.",
styles["body"]
))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph("The cycle has two main phases:", styles["body"]))
main_phases = [
["Phase", "Description", "Duration"],
["SYSTOLE", "Heart muscle contracting — blood is pumped OUT", "~0.3 sec"],
["DIASTOLE", "Heart muscle relaxing — chambers fill with blood", "~0.5 sec"],
]
story.append(coloured_table(main_phases,
col_widths=[3.5*cm, 10*cm, 3*cm],
header_bg=DEEP_RED))
story.append(Spacer(1, 0.4*cm))
story.append(Paragraph("How Each Cycle is Triggered", styles["h2"]))
trigger_steps = [
("1", "SA Node fires", "Located in right atrium near SVC. Acts as the natural pacemaker."),
("2", "Atrial conduction", "Electrical impulse spreads across both atria → atria contract (P wave on ECG)."),
("3", "AV Node delay", "Signal slows for ~0.1 second at AV node → atria finish emptying into ventricles."),
("4", "Bundle of His", "Signal travels down interventricular septum via Bundle of His."),
("5", "Purkinje fibers", "Signal spreads to ventricular walls → ventricles contract (QRS on ECG)."),
]
for num, title, desc in trigger_steps:
row_data = [[
Paragraph(num, ParagraphStyle("n", fontSize=13, fontName="Helvetica-Bold",
textColor=WHITE, alignment=TA_CENTER)),
Paragraph(f"<b>{title}</b><br/>{desc}", styles["phase_body"])
]]
step_tbl = Table(row_data, colWidths=[1.0*cm, PAGE_W - 2*MARGIN - 1.0*cm - 4])
step_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (0,-1), MED_BLUE),
("BACKGROUND", (1,0), (1,-1), LIGHT_BLUE),
("TOPPADDING", (0,0), (-1,-1), 5),
("BOTTOMPADDING", (0,0), (-1,-1), 5),
("LEFTPADDING", (0,0), (-1,-1), 6),
("RIGHTPADDING", (0,0), (-1,-1), 6),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
("BOX", (0,0), (-1,-1), 0.5, colors.HexColor("#AED6F1")),
]))
story.append(step_tbl)
story.append(Spacer(1, 0.15*cm))
story.append(Spacer(1, 0.2*cm))
story.append(info_box(
"The <b>AV node delay (0.1 sec)</b> is essential — it lets the atria finish contracting "
"and emptying blood into the ventricles BEFORE the ventricles contract. "
"Without this delay, the atria and ventricles would contract simultaneously and ventricular filling would be incomplete.",
label="Why the AV Delay Matters"
))
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 2 - THE 7 PHASES
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("2. The 7 Phases of the Cardiac Cycle", DEEP_RED))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph(
"The cardiac cycle is divided into 7 phases. Study each phase by correlating: "
"<b>ECG event → valve status → pressure change → volume change → heart sound.</b>",
styles["body"]
))
story.append(Spacer(1, 0.3*cm))
phase_colors = [
colors.HexColor("#1A5276"), # 1 - late diastole
colors.HexColor("#1E8449"), # 2 - atrial systole
colors.HexColor("#C0392B"), # 3 - IVC
colors.HexColor("#E74C3C"), # 4 - rapid ejection
colors.HexColor("#CA6F1E"), # 5 - reduced ejection
colors.HexColor("#6C3483"), # 6 - IVR
colors.HexColor("#1A6D9A"), # 7 - rapid filling
]
phases = [
(1, "Late Diastole — Passive Filling",
"Before P wave",
"AV valves OPEN | Semilunar valves CLOSED",
"Heart fully relaxed. Blood flows passively from veins → atria → ventricles. ~70% of ventricular filling happens here without any pumping.",
"None"),
(2, "Atrial Systole — The 'Top-Up'",
"P wave",
"AV valves OPEN | Semilunar valves CLOSED",
"Atria contract → push final 30% of blood into ventricles. Ventricle reaches maximum volume = EDV (~130 mL). Venous pulse shows 'a' wave.",
"S4 — abnormal only (stiff ventricle)"),
(3, "Isovolumetric Contraction (IVC) ★",
"QRS complex",
"ALL VALVES CLOSED ← memorise",
"Ventricles contract. Pressure rises sharply. Volume stays fixed. Ends when LV pressure exceeds aortic diastolic pressure (~80 mmHg) → aortic valve forced open. AV valve bulging → 'c' wave on venous pulse.",
"S1 'lub' — mitral valve closes"),
(4, "Rapid Ventricular Ejection",
"ST segment",
"Aortic valve OPEN | Mitral valve CLOSED",
"Blood rushes into aorta. LV pressure peaks at ~120 mmHg. Aortic pressure rises to systolic peak. LV volume falls rapidly. ~2/3 of stroke volume ejected here.",
"None"),
(5, "Reduced Ventricular Ejection",
"T wave",
"Aortic valve OPEN | Mitral valve CLOSED",
"Ejection continues but slows. Pressure begins to fall. LV volume reaches minimum = ESV (~50 mL). Late: aortic pressure exceeds LV pressure but momentum keeps blood moving. Venous pulse 'v' wave appears.",
"None"),
(6, "Isovolumetric Relaxation (IVR) ★",
"After T wave (flat)",
"ALL VALVES CLOSED ← memorise",
"Ventricles relax. Pressure falls rapidly. Volume stays fixed. Ends when LV pressure falls below LA pressure → mitral valve opens.",
"S2 'dub' — aortic valve closes"),
(7, "Rapid Ventricular Filling",
"After T wave",
"Mitral valve OPEN | Aortic valve CLOSED",
"Mitral valve opens. Blood rushes from atria into ventricles passively. Venous pulse 'y' descent. Cycle now repeats.",
"S3 — abnormal in adults (heart failure)"),
]
for ph in phases:
story.append(KeepTogether([
phase_block(ph[0], ph[1], ph[2], ph[3], ph[4], ph[5], phase_colors[ph[0]-1]),
Spacer(1, 0.25*cm),
]))
story.append(info_box(
"The only TWO phases where <b>ALL valves are closed</b> are:<br/>"
"1. <b>Isovolumetric Contraction (IVC)</b> — beginning of systole<br/>"
"2. <b>Isovolumetric Relaxation (IVR)</b> — beginning of diastole<br/>"
"During both, volume is constant because blood has nowhere to go.",
label="★ HIGH YIELD EXAM POINT", bg=LIGHT_RED, border=DEEP_RED
))
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 3 - SUMMARY TABLE
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("3. Summary Table — All 7 Phases", MED_BLUE))
story.append(Spacer(1, 0.3*cm))
summary_data = [
["Phase", "Valves", "Volume", "Pressure", "ECG", "Sound"],
["1. Late Diastole", "AV open\nSL closed", "Filling", "Low", "—", "—"],
["2. Atrial Systole", "AV open\nSL closed", "↑ to EDV", "Small ↑", "P wave", "S4*"],
["3. IVC ★", "ALL CLOSED", "Fixed", "↑↑ rapidly", "QRS", "S1"],
["4. Rapid Ejection", "AV closed\nSL open", "↓↓", "Peak 120", "ST seg.", "—"],
["5. Reduced Ejection", "AV closed\nSL open", "↓ to ESV", "Falling", "T wave", "—"],
["6. IVR ★", "ALL CLOSED", "Fixed", "↓↓ rapidly", "After T", "S2"],
["7. Rapid Filling", "AV open\nSL closed", "↑", "Low", "—", "S3*"],
]
summary_tbl = Table(summary_data,
colWidths=[3.7*cm, 2.8*cm, 2.0*cm, 2.2*cm, 2.0*cm, 1.7*cm],
repeatRows=1)
summary_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), DARK_BLUE),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 8.5),
("LEADING", (0,0), (-1,-1), 12),
("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.4, colors.HexColor("#BDC3C7")),
("ROWBACKGROUNDS",(0,1), (-1,-1), [LIGHT_BLUE, WHITE]),
("BACKGROUND", (0,3), (-1,3), LIGHT_RED),
("BACKGROUND", (0,6), (-1,6), LIGHT_RED),
("FONTNAME", (0,3), (-1,3), "Helvetica-Bold"),
("FONTNAME", (0,6), (-1,6), "Helvetica-Bold"),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
("ALIGN", (0,0), (-1,-1), "CENTER"),
("ALIGN", (0,0), (0,-1), "LEFT"),
]))
story.append(summary_tbl)
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
"* S3 and S4 are shown in red rows — both are abnormal in adults. "
"AV = Atrioventricular valves (mitral + tricuspid). SL = Semilunar valves (aortic + pulmonary).",
styles["note"]
))
story.append(Spacer(1, 0.5*cm))
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 4 - KEY NUMBERS
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("4. Key Numbers to Memorise", colors.HexColor("#1E8449")))
story.append(Spacer(1, 0.3*cm))
nums_data = [
["Parameter", "Value", "Why It Matters"],
["Duration of 1 cycle (72 bpm)", "0.83 sec", "Inverse of heart rate"],
["Systole duration", "~0.3 sec", "Contraction phase"],
["Diastole duration", "~0.5 sec", "Relaxation/filling phase"],
["End-Diastolic Volume (EDV)", "~130 mL", "Max volume before ejection"],
["End-Systolic Volume (ESV)", "~50 mL", "Residual volume after ejection"],
["Stroke Volume (SV = EDV − ESV)", "~80 mL", "Blood pumped per beat"],
["Ejection Fraction (EF)", "~65%", "SV/EDV × 100; Normal > 55%"],
["Peak LV systolic pressure", "~120 mmHg", "Drives aortic systolic BP"],
["Peak RV systolic pressure", "~25 mmHg", "Much lower — pulmonary circuit"],
["Aortic diastolic pressure", "~80 mmHg", "Pressure to open aortic valve"],
["Pulmonary artery diastolic pressure", "~10 mmHg", "Pressure to open pulmonic valve"],
["AV nodal delay", "~0.1 sec", "Allows atria to empty first"],
["Duration of IVC", "~0.05 sec", "Pressure build-up phase"],
["Duration of IVR", "~0.04–0.08 sec", "Pressure drop phase"],
]
story.append(coloured_table(nums_data,
col_widths=[6.5*cm, 3.0*cm, 7.0*cm],
header_bg=GREEN_DARK, row_bg=GREEN_BG))
story.append(Spacer(1, 0.3*cm))
story.append(info_box(
"<b>EF Formula:</b> EF = (Stroke Volume / EDV) × 100 = (80 / 130) × 100 ≈ <b>65%</b><br/>"
"Normal EF ≥ 55%. EF < 40% = Heart Failure with Reduced EF (HFrEF)",
label="Ejection Fraction — Formula to Remember", bg=GREEN_BG, border=GREEN_DARK
))
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 5 - HEART SOUNDS
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("5. Heart Sounds", colors.HexColor("#6C3483")))
story.append(Spacer(1, 0.3*cm))
sounds_data = [
["Sound", "Timing", "Cause", "Normal?", "Clinical Significance"],
["S1 'lub'", "Start of systole", "Mitral + tricuspid valve CLOSURE", "Yes", "Heard best at apex"],
["S2 'dub'", "Start of diastole", "Aortic + pulmonary valve CLOSURE", "Yes", "Heard best at base"],
["S3", "Early diastole", "Blood hitting a floppy/overloaded ventricle during rapid filling", "Only in young adults", "Adults: Heart failure, DCM, volume overload"],
["S4", "Late diastole (pre-S1)", "Blood hitting a stiff/rigid ventricle during atrial contraction", "Never", "LV hypertrophy, hypertension, HCM"],
]
sounds_tbl = Table(sounds_data,
colWidths=[1.8*cm, 2.5*cm, 4.5*cm, 2.2*cm, 5.4*cm],
repeatRows=1)
sounds_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), colors.HexColor("#6C3483")),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 8.5),
("LEADING", (0,0), (-1,-1), 12),
("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.4, colors.HexColor("#BDC3C7")),
("ROWBACKGROUNDS",(0,1), (-1,-1), [colors.HexColor("#F5EEF8"), WHITE]),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
]))
story.append(sounds_tbl)
story.append(Spacer(1, 0.3*cm))
story.append(info_box(
"<b>Memory trick for S3/S4:</b><br/>"
"S3 = 'Ken-TUCK-y' rhythm (lub-dub-ta) — 3 syllables — 3rd sound<br/>"
"S4 = 'TEN-nes-see' rhythm (ta-lub-dub) — 4th sound comes BEFORE S1<br/><br/>"
"<b>Key rule:</b> S1 = valves CLOSING at START of systole. S2 = valves CLOSING at END of systole. "
"Neither sound is from valves OPENING.",
label="Memory Tricks — Heart Sounds",
bg=colors.HexColor("#F5EEF8"), border=colors.HexColor("#6C3483")
))
story.append(Spacer(1, 0.5*cm))
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 6 - JVP WAVES
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("6. Jugular Venous Pulse (JVP) Waves", colors.HexColor("#CA6F1E")))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph(
"The JVP waveform reflects pressure changes in the right atrium, visible in the jugular vein. "
"It consists of three upward waves (a, c, v) and two downward descents (x, y).",
styles["body"]
))
story.append(Spacer(1, 0.2*cm))
jvp_data = [
["Wave/Descent", "Cause", "Phase of Cycle"],
["a wave (upward)", "Atrial contraction", "Late diastole — atrial systole"],
["c wave (upward)", "AV valve bulging into atrium during IVC", "Early systole (IVC)"],
["x descent (down)", "Atrial relaxation + AV valve moving down", "Systole"],
["v wave (upward)", "Venous filling of atria while AV valves are closed", "Late systole"],
["y descent (down)", "AV valve opens — atria empty into ventricles", "Early diastole"],
]
story.append(coloured_table(jvp_data,
col_widths=[4.0*cm, 8.0*cm, 4.4*cm],
header_bg=ORANGE, row_bg=ORANGE_BG))
story.append(Spacer(1, 0.25*cm))
jvp_clinical = [
["Absent 'a' wave", "Atrial fibrillation (no organised atrial contraction)"],
["Cannon 'a' wave", "Complete heart block / junctional rhythm — atria contract against closed AV valve"],
["Raised JVP", "Right heart failure, cardiac tamponade, SVC obstruction"],
["Absent 'x' descent", "Cardiac tamponade"],
["Absent 'y' descent", "Cardiac tamponade (steep in constrictive pericarditis)"],
]
clin_data = [["Clinical Finding", "Cause"]] + jvp_clinical
story.append(coloured_table(clin_data,
col_widths=[5.5*cm, 11.0*cm],
header_bg=DARK_GREY, row_bg=LIGHT_GREY))
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 7 - VALVE SUMMARY
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("7. Valve Opening & Closing — Quick Reference", DARK_BLUE))
story.append(Spacer(1, 0.3*cm))
valve_data = [
["Valve", "Opens When...", "Closes When...", "Sound on Closing"],
["Mitral", "LV pressure < LA pressure\n(end of IVR)", "LV pressure > LA pressure\n(start of IVC)", "S1"],
["Tricuspid", "RV pressure < RA pressure\n(end of IVR)", "RV pressure > RA pressure\n(start of IVC)", "S1"],
["Aortic", "LV pressure > aortic diastolic\n(~80 mmHg)", "LV pressure < aortic pressure\n(protodiastole → S2)", "S2"],
["Pulmonary", "RV pressure > PA diastolic\n(~10 mmHg)", "RV pressure < PA pressure\n(protodiastole → S2)", "S2"],
]
story.append(coloured_table(valve_data,
col_widths=[2.5*cm, 5.5*cm, 5.5*cm, 3.0*cm],
header_bg=DARK_BLUE))
story.append(Spacer(1, 0.5*cm))
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 8 - ECG CORRELATION
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("8. ECG Correlation with Mechanical Events", MED_BLUE))
story.append(Spacer(1, 0.3*cm))
ecg_data = [
["ECG Event", "Electrical Meaning", "Mechanical Event"],
["P wave", "Atrial depolarisation", "Atrial contraction (atrial systole begins)"],
["PR interval", "AV nodal conduction delay", "Blood filling ventricles"],
["QRS complex", "Ventricular depolarisation", "Ventricular systole begins — IVC starts — S1"],
["ST segment", "Ventricular plateau (sustained contraction)", "Rapid ejection phase"],
["T wave", "Ventricular repolarisation", "Reduced ejection — ventricle beginning to relax"],
["After T wave", "Electrical silence", "IVR → S2 → mitral valve opens → rapid filling"],
]
story.append(coloured_table(ecg_data,
col_widths=[2.8*cm, 5.0*cm, 8.6*cm],
header_bg=MED_BLUE))
story.append(Spacer(1, 0.5*cm))
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 9 - PRESSURE-VOLUME LOOP
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("9. Pressure-Volume (P-V) Loop", colors.HexColor("#1E8449")))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph(
"The P-V loop is a graphical representation of LV pressure vs. LV volume during one cardiac cycle. "
"It is traced counter-clockwise. The <b>area inside the loop = stroke work</b> (work done per beat). "
"The <b>width of the loop = stroke volume</b>.",
styles["body"]
))
story.append(Spacer(1, 0.2*cm))
pv_data = [
["Segment", "Points", "Volume Change", "Pressure Change", "Phase"],
["Diastolic filling", "d → a", "↑ (filling)", "Small ↑", "Passive filling + atrial systole"],
["Isovolumetric contraction", "a → b", "None (fixed)", "↑↑ sharply", "IVC"],
["Ventricular ejection", "b → c", "↓ (ejecting)", "Peaks then falls", "Rapid + reduced ejection"],
["Isovolumetric relaxation", "c → d", "None (fixed)", "↓↓ sharply", "IVR"],
]
story.append(coloured_table(pv_data,
col_widths=[4.0*cm, 1.8*cm, 2.8*cm, 3.0*cm, 4.8*cm],
header_bg=GREEN_DARK, row_bg=GREEN_BG))
story.append(Spacer(1, 0.3*cm))
story.append(info_box(
"• <b>Increased preload</b> (e.g. extra blood volume) → loop shifts RIGHT (larger EDV)<br/>"
"• <b>Increased afterload</b> (e.g. hypertension) → loop is TALLER and NARROWER (less volume ejected)<br/>"
"• <b>Increased contractility</b> (e.g. adrenaline) → loop shifts LEFT (smaller ESV, larger SV)",
label="P-V Loop Changes in Disease — Exam Favourite",
bg=GREEN_BG, border=GREEN_DARK
))
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 10 - EXAM TRAPS
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("10. Exam Traps & Common MCQ Mistakes", DEEP_RED))
story.append(Spacer(1, 0.3*cm))
traps = [
("Trap 1", "S1 ≠ mitral valve OPENING",
"S1 is caused by mitral valve CLOSING. The opening snap of the mitral valve is a pathological sound in mitral stenosis."),
("Trap 2", "Atrial systole happens during diastole",
"The overall cardiac cycle is still in 'diastole' during atrial systole. 'Systole' without a qualifier always refers to VENTRICULAR systole."),
("Trap 3", "All valves closed — TWO phases, not one",
"Both IVC and IVR have all valves closed. Students often forget IVR. In both phases volume is constant."),
("Trap 4", "EF is not 100%",
"~50 mL always remains in the ventricle after ejection (ESV). Normal EF is ~65%, not 100%."),
("Trap 5", "S3 in young adults is NORMAL",
"S3 is physiological in children and athletes under ~40. Only becomes abnormal (suggesting HF/volume overload) in adults over 40."),
("Trap 6", "Aortic pressure briefly exceeds LV pressure at end of systole",
"Yet blood continues to flow forward briefly due to momentum (kinetic energy). The aortic valve only closes when momentum is fully overcome."),
("Trap 7", "Diastole shortens MORE than systole with tachycardia",
"At high heart rates, filling time (diastole) is disproportionately reduced. At 3x normal HR, systole = ~65% of the cycle instead of ~40%."),
("Trap 8", "Coronary perfusion is mainly in DIASTOLE",
"During systole, contracting myocardium compresses coronary vessels. Most coronary blood flow (especially to LV) occurs during diastole."),
]
for title, heading, explanation in traps:
trap_data = [[
Paragraph(title, ParagraphStyle("tn", fontSize=9, fontName="Helvetica-Bold",
textColor=WHITE, alignment=TA_CENTER)),
Paragraph(f"<b>{heading}</b><br/>{explanation}", styles["trap_body"])
]]
trap_tbl = Table(trap_data, colWidths=[1.6*cm, PAGE_W - 2*MARGIN - 1.6*cm - 4])
trap_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (0,-1), DEEP_RED),
("BACKGROUND", (1,0), (1,-1), colors.HexColor("#FEF9F9")),
("TOPPADDING", (0,0), (-1,-1), 7),
("BOTTOMPADDING", (0,0), (-1,-1), 7),
("LEFTPADDING", (0,0), (-1,-1), 7),
("RIGHTPADDING", (0,0), (-1,-1), 7),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
("BOX", (0,0), (-1,-1), 0.5, colors.HexColor("#F5B7B1")),
]))
story.append(trap_tbl)
story.append(Spacer(1, 0.2*cm))
story.append(PageBreak())
# ════════════════════════════════════════════════════════════════════════════════
# SECTION 11 - QUICK MCQ ANSWERS
# ════════════════════════════════════════════════════════════════════════════════
story.append(section_header("11. Quick MCQ Answers", colors.HexColor("#1A6D9A")))
story.append(Spacer(1, 0.3*cm))
qas = [
("What is the duration of one cardiac cycle at 72 bpm?",
"~0.83 seconds (reciprocal of heart rate)"),
("What event triggers the start of the cardiac cycle?",
"Spontaneous depolarisation of the SA node (sinus node)"),
("What causes S1?",
"Closure of the mitral and tricuspid (AV) valves at the start of ventricular systole"),
("What causes S2?",
"Closure of the aortic and pulmonary (semilunar) valves at the start of diastole"),
("During which two phases are ALL heart valves closed?",
"Isovolumetric Contraction (IVC) and Isovolumetric Relaxation (IVR)"),
("What is the normal ejection fraction?",
"~65% (normal ≥ 55%; HFrEF defined as EF < 40%)"),
("What is EDV and ESV?",
"EDV ~130 mL (before ejection); ESV ~50 mL (after ejection); SV = 80 mL"),
("What does the 'a' wave on JVP represent?",
"Atrial contraction — rise in atrial pressure during atrial systole"),
("Why is diastole important at high heart rates?",
"Diastole shortens more than systole, reducing ventricular filling time and cardiac output"),
("What is the significance of the AV nodal delay?",
"Ensures atria fully contract and empty blood into ventricles BEFORE ventricular contraction begins"),
("When is S3 pathological?",
"In adults over 40 — suggests heart failure, dilated cardiomyopathy, or volume overload"),
("What is a cannon 'a' wave on JVP?",
"Atria contracting against closed AV valve — seen in complete heart block, junctional rhythm"),
("What does the area inside the P-V loop represent?",
"Stroke work — the mechanical work done by the ventricle per beat"),
("What LV pressure must be exceeded for the aortic valve to open?",
"Aortic diastolic pressure ~80 mmHg"),
]
for i, (q, a) in enumerate(qas):
qa_data = [[
Paragraph(f"Q{i+1}. {q}", styles["qbox"]),
], [
Paragraph(f"A: {a}", styles["abox"]),
]]
qa_tbl = Table(qa_data, colWidths=[PAGE_W - 2*MARGIN])
qa_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), LIGHT_BLUE),
("BACKGROUND", (0,1), (-1,1), WHITE),
("TOPPADDING", (0,0), (-1,-1), 5),
("BOTTOMPADDING", (0,0), (-1,-1), 5),
("LEFTPADDING", (0,0), (-1,-1), 9),
("RIGHTPADDING", (0,0), (-1,-1), 9),
("BOX", (0,0), (-1,-1), 0.8, MED_BLUE),
("LINEBELOW", (0,0), (-1,0), 0.5, colors.HexColor("#AED6F1")),
("VALIGN", (0,0), (-1,-1), "TOP"),
]))
story.append(qa_tbl)
story.append(Spacer(1, 0.2*cm))
story.append(Spacer(1, 0.4*cm))
story.append(HRFlowable(width="100%", thickness=1, color=MED_GREY))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
"<b>Sources:</b> Guyton & Hall Textbook of Medical Physiology | "
"Costanzo Physiology 7th Edition | Ganong's Review of Medical Physiology 26th Edition | "
"Miller's Anesthesia 10th Edition",
styles["footer"]
))
story.append(Paragraph(
"Generated by Orris Medical AI | For educational use only",
styles["footer"]
))
# ── Build ─────────────────────────────────────────────────────────────────────
doc.build(story, onFirstPage=on_first_page, onLaterPages=on_later_pages)
print(f"PDF generated: {OUTPUT}")