~/hand-exam-checklist/generate_checklist.py
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether
)
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.pdfgen import canvas
from reportlab.platypus import BaseDocTemplate, PageTemplate, Frame
OUTPUT = "/home/daytona/workspace/hand-exam-checklist/Intrinsic_Hand_Muscles_Examination_Checklist.pdf"
# ── Colour palette ──────────────────────────────────────────────────────────
NAVY = colors.HexColor("#1B3A5C")
TEAL = colors.HexColor("#1E7A8F")
TEAL_LITE = colors.HexColor("#D6EEF3")
AMBER = colors.HexColor("#E8A020")
AMBER_LITE= colors.HexColor("#FFF4DC")
GREEN = colors.HexColor("#2E7D32")
RED_LITE = colors.HexColor("#FDECEA")
GREY_LITE = colors.HexColor("#F4F6F8")
GREY_MID = colors.HexColor("#D0D7E0")
WHITE = colors.white
BLACK = colors.HexColor("#1A1A1A")
PAGE_W, PAGE_H = A4
MARGIN = 1.5 * cm
# ── Styles ──────────────────────────────────────────────────────────────────
styles = getSampleStyleSheet()
def sty(name, **kw):
return ParagraphStyle(name, **kw)
TITLE = sty("TITLE", fontName="Helvetica-Bold", fontSize=18,
textColor=WHITE, alignment=TA_CENTER, leading=22, spaceAfter=2)
SUBTITLE = sty("SUBTITLE", fontName="Helvetica", fontSize=10,
textColor=colors.HexColor("#CCE8F0"), alignment=TA_CENTER, leading=13)
DATE_STYLE = sty("DATE_STYLE", fontName="Helvetica", fontSize=8,
textColor=GREY_MID, alignment=TA_CENTER)
H1 = sty("H1", fontName="Helvetica-Bold", fontSize=11,
textColor=WHITE, leading=14, alignment=TA_LEFT)
H2 = sty("H2", fontName="Helvetica-Bold", fontSize=9,
textColor=NAVY, leading=12, spaceBefore=3, spaceAfter=1)
BODY = sty("BODY", fontName="Helvetica", fontSize=8,
textColor=BLACK, leading=11)
BODY_BOLD = sty("BODY_BOLD", fontName="Helvetica-Bold", fontSize=8,
textColor=BLACK, leading=11)
SMALL = sty("SMALL", fontName="Helvetica", fontSize=7,
textColor=colors.HexColor("#555555"), leading=10)
NERVE_MED = sty("NERVE_MED", fontName="Helvetica-Bold", fontSize=7,
textColor=colors.HexColor("#1565C0"), leading=10)
NERVE_ULN = sty("NERVE_ULN", fontName="Helvetica-Bold", fontSize=7,
textColor=colors.HexColor("#6A1B9A"), leading=10)
WARN = sty("WARN", fontName="Helvetica-BoldOblique", fontSize=7.5,
textColor=colors.HexColor("#B71C1C"), leading=11)
def checkbox():
return "□"
def checked():
return "☑"
# ── Helper: section header row ───────────────────────────────────────────────
def section_header(title, subtitle=""):
cell_text = Paragraph(title, H1)
sub_para = Paragraph(subtitle, sty("SH_SUB", fontName="Helvetica",
fontSize=7.5, textColor=colors.HexColor("#A8D8E8"),
leading=11)) if subtitle else Paragraph("", SMALL)
tbl = Table([[cell_text], [sub_para]],
colWidths=[PAGE_W - 2*MARGIN])
tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), NAVY),
("BACKGROUND", (0,1), (-1,1), TEAL),
("LEFTPADDING", (0,0), (-1,-1), 8),
("RIGHTPADDING", (0,0), (-1,-1), 8),
("TOPPADDING", (0,0), (-1,-1), 5),
("BOTTOMPADDING",(0,0), (-1,-1), 4),
]))
return tbl
# ── MRC Grade row ─────────────────────────────────────────────────────────────
def mrc_table():
headers = ["Grade", "Description", "Functional Meaning"]
rows = [
["0", "No contraction", "Complete paralysis"],
["1", "Flicker / trace only — no movement", "Visible or palpable twitch"],
["2", "Movement with gravity eliminated", "Horizontal plane movement only"],
["3", "Movement against gravity", "Cannot overcome any resistance"],
["4−/4/4+", "Movement vs. slight / moderate / strong resistance", "Reduced but present"],
["5", "Normal power", "Full resistance without break"],
]
data = [headers] + rows
col_w = [(PAGE_W - 2*MARGIN) * f for f in [0.10, 0.52, 0.38]]
tbl = Table(data, colWidths=col_w)
style = TableStyle([
("BACKGROUND", (0,0), (-1,0), TEAL),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 7.5),
("FONTNAME", (0,1), (-1,-1), "Helvetica"),
("ROWBACKGROUNDS", (0,1), (-1,-1), [WHITE, GREY_LITE]),
("GRID", (0,0), (-1,-1), 0.4, GREY_MID),
("LEFTPADDING", (0,0), (-1,-1), 5),
("RIGHTPADDING",(0,0), (-1,-1), 5),
("TOPPADDING", (0,0), (-1,-1), 3),
("BOTTOMPADDING",(0,0), (-1,-1), 3),
("ALIGN", (0,0), (0,-1), "CENTER"),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
("FONTNAME", (0,4), (0,4), "Helvetica-Bold"),
("BACKGROUND", (0,5), (-1,5), colors.HexColor("#E8F5E9")),
("FONTNAME", (0,5), (-1,5), "Helvetica-Bold"),
])
tbl.setStyle(style)
return tbl
# ── Muscle examination table ──────────────────────────────────────────────────
def muscle_table(muscles):
"""
muscles: list of dicts with keys:
name, nerve, root, position, instruction, palpation, normal, r_grade, l_grade, notes
"""
headers = ["Muscle", "Nerve\n(Root)", "Patient Position\n& Instruction",
"Palpation / Trick", "R\nGrade", "L\nGrade", "Notes / Signs"]
col_w = [(PAGE_W - 2*MARGIN) * f for f in [0.17, 0.10, 0.28, 0.19, 0.06, 0.06, 0.14]]
data = [headers]
for m in muscles:
nerve_style = NERVE_MED if "Median" in m["nerve"] else NERVE_ULN
data.append([
Paragraph(f"<b>{m['name']}</b>", BODY_BOLD),
Paragraph(f"<font color='#{('1565C0' if 'Median' in m['nerve'] else '6A1B9A')}'>"
f"<b>{m['nerve']}</b></font><br/>"
f"<font size='6.5'>{m['root']}</font>", SMALL),
Paragraph(m["instruction"], BODY),
Paragraph(m["palpation"], SMALL),
Paragraph(checkbox(), sty("CB", fontName="Helvetica", fontSize=14,
alignment=TA_CENTER, leading=16)),
Paragraph(checkbox(), sty("CB2", fontName="Helvetica", fontSize=14,
alignment=TA_CENTER, leading=16)),
Paragraph(m.get("notes",""), SMALL),
])
tbl = Table(data, colWidths=col_w, repeatRows=1)
style = TableStyle([
# Header
("BACKGROUND", (0,0), (-1,0), TEAL),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,0), 7.5),
("ALIGN", (0,0), (-1,0), "CENTER"),
# Body
("ROWBACKGROUNDS", (0,1), (-1,-1), [WHITE, GREY_LITE]),
("FONTSIZE", (0,1), (-1,-1), 7.5),
("GRID", (0,0), (-1,-1), 0.4, GREY_MID),
("LEFTPADDING", (0,0), (-1,-1), 4),
("RIGHTPADDING", (0,0), (-1,-1), 4),
("TOPPADDING", (0,0), (-1,-1), 3),
("BOTTOMPADDING",(0,0), (-1,-1), 3),
("VALIGN", (0,0), (-1,-1), "TOP"),
("ALIGN", (4,0), (5,-1), "CENTER"),
("ALIGN", (4,1), (5,-1), "CENTER"),
# Grade columns highlight
("BACKGROUND", (4,0), (5,0), AMBER),
("TEXTCOLOR", (4,0), (5,0), WHITE),
])
tbl.setStyle(style)
return tbl
# ── Special tests table ────────────────────────────────────────────────────────
def special_tests_table():
headers = ["Test", "Technique", "Positive Finding", "Nerve / Muscle", "R □", "L □"]
rows = [
["Froment's Sign",
"Patient grips paper between thumb & index finger; examiner pulls paper",
"Thumb IP flexes (FPL substitutes for weak adductor pollicis)",
"Adductor pollicis\nUlnar nerve", checkbox(), checkbox()],
["Wartenberg's Sign",
"Patient holds all fingers extended together; observe for 30 s",
"Little finger drifts into abduction (ADM not opposed by 3rd palmar interosseous)",
"Interossei / ADM\nUlnar nerve", checkbox(), checkbox()],
["Card Test\n(Palmar interossei)",
"Slip paper card between two adjacent extended fingers; attempt to pull card out",
"Cannot grip card — card slips out easily",
"Palmar interossei\nUlnar nerve", checkbox(), checkbox()],
["Finger Abduction\n(Dorsal interossei)",
"Patient spreads all fingers wide; examiner resists each abduction",
"Weakness or inability to abduct fingers",
"Dorsal interossei\nUlnar nerve", checkbox(), checkbox()],
["Opposition Test",
"Patient touches thumb tip to little finger tip; examiner resists pinch",
"Inability to complete full opposition; thenar flattening",
"Opponens pollicis\nMedian nerve", checkbox(), checkbox()],
["APB Isolation Test",
"Palm flat on table; patient lifts thumb perpendicular to palm against resistance",
"MRC < 4; thenar wasting visible/palpable",
"Abductor pollicis brevis\nMedian nerve", checkbox(), checkbox()],
["Bunnell Intrinsic\nTightness Test",
"Passively flex PIP (1) with MCP extended, then (2) with MCP flexed",
"PIP only flexes freely when MCP flexed → intrinsic tightness/contracture",
"Intrinsic muscles\n(general)", checkbox(), checkbox()],
["OK / Circle Sign",
"Ask patient to make 'OK' circle with thumb & index",
"Cannot make circle — pinches side of index DIP instead → AIN palsy",
"FPL + FDP index\nAnterior Interosseous N.", checkbox(), checkbox()],
["Grip Dynamometry",
"Standardised hand-grip dynamometer, 3 attempts per side",
"Record in kg; compare to normative values & contralateral side",
"Overall intrinsic\n+ extrinsic", "___kg", "___kg"],
["Pinch Dynamometry",
"Key/lateral pinch dynamometer; 3 attempts per side",
"Record in kg; compare sides",
"Adductor pollicis\nFPB / APB", "___kg", "___kg"],
]
data = [headers] + rows
col_w = [(PAGE_W - 2*MARGIN) * f for f in [0.16, 0.28, 0.24, 0.18, 0.07, 0.07]]
tbl = Table(data, colWidths=col_w, repeatRows=1)
style = TableStyle([
("BACKGROUND", (0,0), (-1,0), NAVY),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 7.5),
("ROWBACKGROUNDS", (0,1), (-1,-1), [WHITE, GREY_LITE]),
("GRID", (0,0), (-1,-1), 0.4, GREY_MID),
("FONTNAME", (0,1), (-1,-1), "Helvetica"),
("LEFTPADDING", (0,0), (-1,-1), 4),
("RIGHTPADDING", (0,0), (-1,-1), 4),
("TOPPADDING", (0,0), (-1,-1), 3),
("BOTTOMPADDING",(0,0), (-1,-1), 3),
("VALIGN", (0,0), (-1,-1), "TOP"),
("ALIGN", (4,0), (5,-1), "CENTER"),
("FONTNAME", (0,1), (0,-1), "Helvetica-Bold"),
("BACKGROUND", (4,0), (5,0), AMBER),
])
tbl.setStyle(style)
return tbl
# ── Inspection checklist ────────────────────────────────────────────────────────
def inspection_table():
items = [
[checkbox(), "Thenar wasting (flattening of thenar eminence)",
"Median nerve palsy / CTS / C8-T1 lesion", ""],
[checkbox(), "Hypothenar wasting (ulnar eminence flattening)",
"Ulnar nerve palsy at wrist or elbow", ""],
[checkbox(), "Dorsal guttering (hollowing between metacarpals)",
"Interossei atrophy — ulnar nerve palsy", ""],
[checkbox(), "Claw hand (ring & little — MCP hyperext + IP flex)",
"Low ulnar nerve palsy (ulnar paradox)", ""],
[checkbox(), "Claw all fingers (index to little)",
"High ulnar + median palsy OR C8/T1 lesion", ""],
[checkbox(), "Ape thumb / flat hand (loss of thenar bulk + opposition)",
"Median nerve palsy (recurrent branch)", ""],
[checkbox(), "Simian hand (thumb in plane of palm, no abduction/opposition)",
"Combined median + ulnar palsy", ""],
[checkbox(), "Skin trophic changes / sweating abnormality",
"Autonomic involvement — check nerve territory", ""],
]
headers = ["□", "Finding", "Significance", "Side / Notes"]
col_w = [(PAGE_W - 2*MARGIN) * f for f in [0.04, 0.42, 0.36, 0.18]]
data = [headers] + items
tbl = Table(data, colWidths=col_w, repeatRows=1)
style = TableStyle([
("BACKGROUND", (0,0), (-1,0), TEAL),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 7.5),
("ROWBACKGROUNDS", (0,1), (-1,-1), [WHITE, GREY_LITE]),
("GRID", (0,0), (-1,-1), 0.4, GREY_MID),
("FONTNAME", (0,1), (-1,-1), "Helvetica"),
("LEFTPADDING", (0,0), (-1,-1), 4),
("RIGHTPADDING", (0,0), (-1,-1), 4),
("TOPPADDING", (0,0), (-1,-1), 3),
("BOTTOMPADDING",(0,0), (-1,-1), 3),
("VALIGN", (0,0), (-1,-1), "TOP"),
("ALIGN", (0,1), (0,-1), "CENTER"),
("FONTSIZE", (0,1), (0,-1), 12),
])
tbl.setStyle(style)
return tbl
# ── Nerve injury quick-ref ────────────────────────────────────────────────────
def nerve_ref_table():
headers = ["Nerve Injured", "Level", "Intrinsic Muscles Lost", "Classic Sign"]
rows = [
["Median nerve\n(recurrent motor branch)",
"Wrist / carpal tunnel",
"APB, Opponens pollicis, FPB (superficial), Lumb 1&2",
"Ape hand; cannot oppose thumb"],
["Ulnar nerve",
"Wrist (Guyon's canal)",
"ALL hypothenar, ALL interossei, Adductor pollicis, FPB (deep), Lumb 3&4",
"Claw hand (ring & little); Froment's +ve; dorsal guttering"],
["Ulnar nerve",
"Elbow (cubital tunnel)",
"Same as above + FCU + medial FDP",
"Claw LESS pronounced (ulnar paradox — FDP also weak)"],
["Combined Median\n+ Ulnar",
"Wrist",
"All 20 intrinsic muscles",
"Simian hand; no grip or pinch; complete intrinsic loss"],
["C8 / T1 roots\nor lower trunk",
"Brachial plexus",
"All intrinsics (both median + ulnar supply)",
"Bilateral small hand wasting (Hirayama, Pancoast, etc.)"],
]
data = [headers] + rows
col_w = [(PAGE_W - 2*MARGIN) * f for f in [0.18, 0.14, 0.40, 0.28]]
tbl = Table(data, colWidths=col_w, repeatRows=1)
style = TableStyle([
("BACKGROUND", (0,0), (-1,0), NAVY),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 7.5),
("ROWBACKGROUNDS", (0,1), (-1,-1), [WHITE, GREY_LITE]),
("GRID", (0,0), (-1,-1), 0.4, GREY_MID),
("FONTNAME", (0,1), (-1,-1), "Helvetica"),
("LEFTPADDING", (0,0), (-1,-1), 4),
("RIGHTPADDING", (0,0), (-1,-1), 4),
("TOPPADDING", (0,0), (-1,-1), 3),
("BOTTOMPADDING",(0,0), (-1,-1), 3),
("VALIGN", (0,0), (-1,-1), "TOP"),
])
tbl.setStyle(style)
return tbl
# ── Patient info header ────────────────────────────────────────────────────────
def patient_info_table():
row1 = [
Paragraph("Patient Name: _______________________________", BODY),
Paragraph("Date: _______________________", BODY),
Paragraph("Examiner: _______________________", BODY),
]
row2 = [
Paragraph("Age: _____ / Sex: M □ F □ / Hand dominance: R □ L □", BODY),
Paragraph("Diagnosis / Provisional: ________________________________", BODY),
Paragraph("MRN / ID: _______________________", BODY),
]
data = [row1, row2]
col_w = [(PAGE_W - 2*MARGIN) * f for f in [0.40, 0.36, 0.24]]
tbl = Table(data, colWidths=col_w)
style = TableStyle([
("BOX", (0,0), (-1,-1), 1, TEAL),
("INNERGRID", (0,0), (-1,-1), 0.4, GREY_MID),
("BACKGROUND", (0,0), (-1,-1), TEAL_LITE),
("LEFTPADDING", (0,0), (-1,-1), 6),
("RIGHTPADDING", (0,0), (-1,-1), 6),
("TOPPADDING", (0,0), (-1,-1), 5),
("BOTTOMPADDING",(0,0), (-1,-1), 5),
("FONTNAME", (0,0), (-1,-1), "Helvetica"),
("FONTSIZE", (0,0), (-1,-1), 8),
])
tbl.setStyle(style)
return tbl
# ── Summary score box ─────────────────────────────────────────────────────────
def summary_box():
data = [
[Paragraph("<b>EXAMINATION SUMMARY</b>", sty("SH", fontName="Helvetica-Bold",
fontSize=9, textColor=WHITE, alignment=TA_CENTER)), "", "", ""],
[Paragraph("Thenar group (R/L): ___/___", BODY),
Paragraph("Hypothenar group (R/L): ___/___", BODY),
Paragraph("Lumbricals (R/L): ___/___", BODY),
Paragraph("Interossei (R/L): ___/___", BODY)],
[Paragraph("Grip dynamometry (R/L): ___ / ___ kg", BODY),
Paragraph("Pinch dynamometry (R/L): ___ / ___ kg", BODY),
Paragraph("Froment's □ Wartenberg's □ Card □", BODY),
Paragraph("Nerve suspected: ___________________", BODY)],
[Paragraph("Overall functional assessment: □ Normal □ Mild loss □ Moderate loss □ Severe loss □ Complete", BODY),
"", "", ""],
[Paragraph("Clinical impression / Plan:", BODY_BOLD),
"", "", ""],
[Paragraph("_" * 100, SMALL), "", "", ""],
]
spans = [(0,0,3,0), (0,3,3,3), (0,4,3,4), (0,5,3,5)]
col_w = [(PAGE_W - 2*MARGIN) * 0.25] * 4
tbl = Table(data, colWidths=col_w)
style = TableStyle([
("BACKGROUND", (0,0), (-1,0), NAVY),
("TEXTCOLOR", (0,0), (-1,0), WHITE),
("BACKGROUND", (0,1), (-1,-1), AMBER_LITE),
("BOX", (0,0), (-1,-1), 1, NAVY),
("INNERGRID", (0,0), (-1,-1), 0.3, GREY_MID),
("LEFTPADDING", (0,0), (-1,-1), 6),
("RIGHTPADDING",(0,0), (-1,-1), 6),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING",(0,0), (-1,-1), 4),
("SPAN", (0,0), (3,0)),
("SPAN", (0,3), (3,3)),
("SPAN", (0,4), (3,4)),
("SPAN", (0,5), (3,5)),
])
tbl.setStyle(style)
return tbl
# ── Page number canvas ────────────────────────────────────────────────────────
def add_page_number(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 7)
canvas.setFillColor(colors.HexColor("#888888"))
canvas.drawRightString(PAGE_W - MARGIN, 0.7*cm,
f"Page {doc.page} | Intrinsic Hand Muscles — Clinical Examination Checklist")
canvas.drawString(MARGIN, 0.7*cm, "© Clinical Examination Tool — For Educational Use")
canvas.restoreState()
# ── MAIN BUILD ────────────────────────────────────────────────────────────────
def build():
doc = SimpleDocTemplate(
OUTPUT,
pagesize=A4,
leftMargin=MARGIN, rightMargin=MARGIN,
topMargin=MARGIN, bottomMargin=1.5*cm,
title="Intrinsic Hand Muscles — Clinical Examination Checklist",
author="Orris Clinical Tools",
)
story = []
# ── TITLE BLOCK ─────────────────────────────────────────────────────────
title_data = [[
Paragraph("INTRINSIC HAND MUSCLES", TITLE),
Paragraph("Clinical Examination Checklist", SUBTITLE),
Paragraph("Thenar · Hypothenar · Lumbricals · Interossei", SUBTITLE),
Paragraph("Nerve roots: C8, T1 | Nerves: Median & Ulnar", DATE_STYLE),
]]
title_tbl = Table([[
Paragraph("INTRINSIC HAND MUSCLES", TITLE)],
[Paragraph("Clinical Examination Checklist", SUBTITLE)],
[Paragraph("Thenar · Hypothenar · Lumbricals · Interossei | Nerve roots: C8, T1", SUBTITLE)],
], colWidths=[PAGE_W - 2*MARGIN])
title_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), NAVY),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING",(0,0), (-1,-1), 4),
("LEFTPADDING", (0,0), (-1,-1), 10),
("RIGHTPADDING",(0,0), (-1,-1), 10),
]))
story.append(title_tbl)
story.append(Spacer(1, 4*mm))
# ── PATIENT INFO ─────────────────────────────────────────────────────────
story.append(patient_info_table())
story.append(Spacer(1, 4*mm))
# ── STEP 1: INSPECTION ───────────────────────────────────────────────────
story.append(section_header("STEP 1 — INSPECTION", "Observe both hands at rest, dorsum and palm"))
story.append(Spacer(1, 2*mm))
story.append(inspection_table())
story.append(Spacer(1, 5*mm))
# ── STEP 2: MRC GRADING REFERENCE ────────────────────────────────────────
story.append(section_header("MRC POWER GRADING SCALE — Reference",
"Grade each muscle Right (R) and Left (L) using 0–5 scale"))
story.append(Spacer(1, 2*mm))
story.append(mrc_table())
story.append(Spacer(1, 5*mm))
# ── STEP 3: THENAR MUSCLES ───────────────────────────────────────────────
story.append(section_header("STEP 2 — THENAR MUSCLES",
"Median nerve (C8, T1) — except adductor pollicis (ulnar)"))
story.append(Spacer(1, 2*mm))
thenar = [
{
"name": "Abductor Pollicis\nBrevis (APB)",
"nerve": "Median", "root": "C8, T1",
"instruction": (
"Hand palm-up flat on table. Ask: 'Lift your thumb straight up toward "
"the ceiling.' Examiner applies downward resistance at thumb proximal phalanx."
),
"palpation": "Palpate lateral thenar eminence for muscle belly contraction.",
"notes": "Most sensitive test for CTS motor loss.\nWasting = thenar flattening."
},
{
"name": "Opponens\nPollicis (OP)",
"nerve": "Median", "root": "C8, T1",
"instruction": (
"Ask patient to touch thumb tip to little finger tip. Examiner tries to "
"pull thumb away from little finger. Assess rotational component of opposition."
),
"palpation": "Palpate deep to APB along 1st metacarpal shaft.",
"notes": "Loss = inability to rotate thumb pad-to-pad.\nKey for buttoning, writing."
},
{
"name": "Flexor Pollicis\nBrevis (FPB)",
"nerve": "Median\n(sup. head)\nUlnar\n(deep head)", "root": "C8, T1",
"instruction": (
"Ask patient to flex thumb at MCP joint (keeping IP extended). "
"Examiner resists MCP flexion."
),
"palpation": "Palpate medial thenar eminence proximal to MCP joint.",
"notes": "Dual innervation; rarely completely lost in single nerve injury."
},
{
"name": "Adductor\nPollicis (AP)",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"Ask patient to grip paper between extended thumb and radial side of index "
"finger. Examiner pulls paper out (Froment's test)."
),
"palpation": "Palpate 1st web space on palmar aspect (deep to APB).",
"notes": "Froment's +ve = IP flexion (FPL compensates).\nWasting = 1st web space hollowing."
},
]
story.append(muscle_table(thenar))
story.append(Spacer(1, 5*mm))
# ── STEP 3: HYPOTHENAR MUSCLES ───────────────────────────────────────────
story.append(section_header("STEP 3 — HYPOTHENAR MUSCLES",
"All: Ulnar nerve (C8, T1) — originate from flexor retinaculum & hamate"))
story.append(Spacer(1, 2*mm))
hypothenar = [
{
"name": "Abductor Digiti\nMinimi (ADM)",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"Hand flat on table. Ask patient to move little finger away from ring finger. "
"Examiner resists at ulnar side of little finger proximal phalanx."
),
"palpation": "Palpate ulnar border of the hand — ADM is the most superficial hypothenar muscle.",
"notes": "Most accessible hypothenar muscle to test.\nFirst sign of ulnar motor loss at wrist."
},
{
"name": "Flexor Digiti\nMinimi Brevis\n(FDMB)",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"Ask patient to flex little finger at MCP joint against resistance "
"while keeping PIP and DIP extended."
),
"palpation": "Palpate volar-ulnar aspect of little finger base.",
"notes": "Often tested with ADM together.\nWasting = hypothenar eminence flattening."
},
{
"name": "Opponens Digiti\nMinimi (ODM)",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"Ask patient to cup the palm (as if holding water). Examiner attempts to flatten "
"the palm. Alternatively, ask patient to bring little finger toward thumb."
),
"palpation": "Deep to ADM and FDMB along ulnar border of 5th metacarpal.",
"notes": "Loss limits ability to cup palm.\nRelevant for holding bowls, jars, cans."
},
]
story.append(muscle_table(hypothenar))
story.append(Spacer(1, 5*mm))
# ── STEP 4: LUMBRICALS ───────────────────────────────────────────────────
story.append(section_header("STEP 4 — LUMBRICAL MUSCLES (4)",
"1st & 2nd: Median nerve | 3rd & 4th: Ulnar nerve (C8, T1)"))
story.append(Spacer(1, 2*mm))
lumbricals = [
{
"name": "1st Lumbrical\n(Index finger)",
"nerve": "Median", "root": "C8, T1",
"instruction": (
"Ask patient to flex index finger MCP joint to 90° while keeping "
"PIP and DIP joints fully extended ('tabletop position'). "
"Examiner resists MCP flexion."
),
"palpation": "Arise from FDP tendons — palpate in palm radial to index metacarpal.",
"notes": "Tests 'intrinsic plus' position.\nLoss → index claw tendency."
},
{
"name": "2nd Lumbrical\n(Middle finger)",
"nerve": "Median", "root": "C8, T1",
"instruction": (
"Same as above for middle finger — MCP flexion with PIP/DIP extended. "
"Resist at proximal phalanx."
),
"palpation": "Palpate in palm just ulnar to 2nd metacarpal.",
"notes": "Fine motor tasks (typing, writing) depend heavily on 2nd lumbrical."
},
{
"name": "3rd Lumbrical\n(Ring finger)",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"'Tabletop position' for ring finger. MCP to 90°, PIP/DIP straight. "
"Resist at proximal phalanx."
),
"palpation": "Palpate radial to 4th metacarpal in palm.",
"notes": "Loss contributes to ring finger clawing in ulnar nerve palsy."
},
{
"name": "4th Lumbrical\n(Little finger)",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"'Tabletop position' for little finger. Resist MCP flexion while "
"checking PIP/DIP remain extended."
),
"palpation": "Palpate radial to 5th metacarpal.",
"notes": "Loss → greatest claw in little finger.\nMost pronounced in low ulnar palsy."
},
]
story.append(muscle_table(lumbricals))
# Lumbrical note
story.append(Spacer(1, 2*mm))
note_data = [[Paragraph(
"⚠ CLINICAL NOTE — INTRINSIC PLUS POSITION: MCP flexed + PIP/DIP extended. "
"Loss of lumbrical function produces the opposite: MCP hyperextended + IP flexed = "
"<b>CLAW HAND (intrinsic minus)</b>. In ulnar palsy this affects ring & little fingers "
"(3rd & 4th lumbricals lost). In combined palsy all 4 fingers claw.", WARN)]]
note_tbl = Table(note_data, colWidths=[PAGE_W - 2*MARGIN])
note_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,-1), RED_LITE),
("BOX", (0,0), (-1,-1), 1, colors.HexColor("#EF9A9A")),
("LEFTPADDING", (0,0), (-1,-1), 8),
("RIGHTPADDING", (0,0), (-1,-1), 8),
("TOPPADDING", (0,0), (-1,-1), 5),
("BOTTOMPADDING",(0,0), (-1,-1), 5),
]))
story.append(note_tbl)
story.append(Spacer(1, 5*mm))
# ── STEP 5: INTEROSSEI ───────────────────────────────────────────────────
story.append(section_header("STEP 5 — INTEROSSEI MUSCLES (7)",
"All: Ulnar nerve (C8, T1) | DAB = Dorsal ABduct | PAD = Palmar ADduct"))
story.append(Spacer(1, 2*mm))
interossei = [
{
"name": "1st Dorsal\nInterosseous\n(FDI)",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"Hand flat palm-down. Ask patient to move index finger away from middle "
"finger (radially). Examiner resists at radial side of index proximal phalanx."
),
"palpation": "Most accessible interosseous — palpate belly in dorsal 1st web space.",
"notes": "Most testable interosseous.\nWasting → 1st web space hollowing."
},
{
"name": "2nd–4th Dorsal\nInterossei",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"2nd: middle finger abducts radially. 3rd: middle finger abducts ulnarly. "
"4th: ring finger abducts ulnarly. Test each against resistance. "
"Alternatively: patient spreads all fingers against examiner's resistance."
),
"palpation": "Palpate respective intermetacarpal spaces on dorsum.",
"notes": "Dorsal guttering on dorsum = wasting.\nTest all with 'spread fingers' command."
},
{
"name": "1st–3rd Palmar\nInterossei",
"nerve": "Ulnar", "root": "C8, T1",
"instruction": (
"Card test: slip paper between adjacent extended fingers; "
"examiner pulls card while patient grips. "
"Or: ask patient to adduct each finger toward middle finger against resistance."
),
"palpation": "Lie in intermetacarpal spaces on palmar side — difficult to palpate directly.",
"notes": "Card test: cannot grip = palmar interosseous weakness.\nAdduction tested against dorsal interossei abduction."
},
]
story.append(muscle_table(interossei))
story.append(Spacer(1, 5*mm))
# ── STEP 6: SPECIAL TESTS ─────────────────────────────────────────────────
story.append(section_header("STEP 6 — SPECIAL CLINICAL TESTS",
"Functional & provocation tests for intrinsic muscle integrity"))
story.append(Spacer(1, 2*mm))
story.append(special_tests_table())
story.append(Spacer(1, 5*mm))
# ── NERVE QUICK REFERENCE ────────────────────────────────────────────────
story.append(section_header("NERVE INJURY QUICK REFERENCE",
"Expected intrinsic muscle loss by nerve level"))
story.append(Spacer(1, 2*mm))
story.append(nerve_ref_table())
story.append(Spacer(1, 5*mm))
# ── SUMMARY BOX ──────────────────────────────────────────────────────────
story.append(summary_box())
story.append(Spacer(1, 3*mm))
# ── FINAL NOTE ────────────────────────────────────────────────────────────
footer_note = Paragraph(
"For educational and clinical reference use only. Always correlate with full history, "
"sensory examination, nerve conduction studies, and imaging as appropriate. "
"MRC grading should be performed with the patient seated comfortably, with gravity "
"appropriately eliminated for grades 0–2.",
sty("FN", fontName="Helvetica-Oblique", fontSize=7,
textColor=colors.HexColor("#666666"), alignment=TA_CENTER, leading=10)
)
story.append(footer_note)
doc.build(story, onFirstPage=add_page_number, onLaterPages=add_page_number)
print(f"PDF created: {OUTPUT}")
build()