~/breast-exam-pdf/create_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, PageBreak, KeepTogether)
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
from reportlab.graphics.shapes import Drawing, Rect, Circle, Line, String, Ellipse, Polygon
from reportlab.graphics import renderPDF
from reportlab.platypus.flowables import Flowable
import io
# ─── Color palette ───────────────────────────────────────────────────────────
TEAL = colors.HexColor("#006B6B")
TEAL_LIGHT = colors.HexColor("#E0F4F4")
TEAL_MED = colors.HexColor("#B2DEDE")
ROSE = colors.HexColor("#C0392B")
ROSE_LIGHT = colors.HexColor("#FDECEA")
GOLD = colors.HexColor("#D4AC0D")
GOLD_LIGHT = colors.HexColor("#FEF9E7")
DARK = colors.HexColor("#1C2833")
MID = colors.HexColor("#4A4A4A")
LIGHT_GRAY = colors.HexColor("#F4F6F9")
BORDER_GRAY = colors.HexColor("#D5D8DC")
WHITE = colors.white
RED_FLAG = colors.HexColor("#E74C3C")
GREEN_OK = colors.HexColor("#1E8449")
# ─── Styles ───────────────────────────────────────────────────────────────────
styles = getSampleStyleSheet()
title_style = ParagraphStyle('Title', fontName='Helvetica-Bold', fontSize=22,
textColor=WHITE, alignment=TA_CENTER, spaceAfter=4)
subtitle_style = ParagraphStyle('Subtitle', fontName='Helvetica', fontSize=11,
textColor=TEAL_LIGHT, alignment=TA_CENTER, spaceAfter=2)
h1 = ParagraphStyle('H1', fontName='Helvetica-Bold', fontSize=13,
textColor=WHITE, alignment=TA_LEFT, spaceAfter=4, leftIndent=6)
h2 = ParagraphStyle('H2', fontName='Helvetica-Bold', fontSize=11,
textColor=TEAL, alignment=TA_LEFT, spaceBefore=8, spaceAfter=4)
h3 = ParagraphStyle('H3', fontName='Helvetica-Bold', fontSize=10,
textColor=DARK, alignment=TA_LEFT, spaceBefore=4, spaceAfter=3)
body = ParagraphStyle('Body', fontName='Helvetica', fontSize=9,
textColor=DARK, alignment=TA_JUSTIFY, spaceAfter=3, leading=13)
bullet = ParagraphStyle('Bullet', fontName='Helvetica', fontSize=9,
textColor=DARK, leftIndent=14, spaceAfter=2, leading=12,
bulletIndent=4)
red_text = ParagraphStyle('Red', fontName='Helvetica-Bold', fontSize=9,
textColor=RED_FLAG, spaceAfter=2)
green_text = ParagraphStyle('Green', fontName='Helvetica-Bold', fontSize=9,
textColor=GREEN_OK, spaceAfter=2)
small = ParagraphStyle('Small', fontName='Helvetica', fontSize=8,
textColor=MID, spaceAfter=2, leading=11)
caption = ParagraphStyle('Caption', fontName='Helvetica-Oblique', fontSize=8,
textColor=MID, alignment=TA_CENTER, spaceAfter=6)
tip_style = ParagraphStyle('Tip', fontName='Helvetica', fontSize=9,
textColor=DARK, leftIndent=10, spaceAfter=2, leading=12)
# ─── Header/Footer ────────────────────────────────────────────────────────────
def add_header_footer(canvas, doc):
canvas.saveState()
W, H = A4
# Top bar
canvas.setFillColor(TEAL)
canvas.rect(0, H - 22*mm, W, 22*mm, fill=True, stroke=False)
canvas.setFillColor(WHITE)
canvas.setFont('Helvetica-Bold', 10)
canvas.drawString(15*mm, H - 13*mm, "BREAST EXAMINATION - Surgical Viva Guide")
canvas.setFont('Helvetica', 8)
canvas.drawRightString(W - 15*mm, H - 13*mm, f"Page {doc.page}")
# Bottom bar
canvas.setFillColor(TEAL)
canvas.rect(0, 0, W, 10*mm, fill=True, stroke=False)
canvas.setFillColor(WHITE)
canvas.setFont('Helvetica', 7)
canvas.drawCentredString(W/2, 3.5*mm, "Based on S Das - A Manual on Clinical Surgery | Bailey & Love | Schwartz's Principles of Surgery")
canvas.restoreState()
# ─── Diagram helpers ─────────────────────────────────────────────────────────
class BreastQuadrantDiagram(Flowable):
"""Breast quadrant anatomy diagram"""
def __init__(self, width=180, height=180):
Flowable.__init__(self)
self.width = width
self.height = height
def draw(self):
c = self.canv
cx, cy = self.width/2, self.height/2
r = 70
# Background circle (breast outline)
c.setFillColor(colors.HexColor("#FFE4E1"))
c.setStrokeColor(colors.HexColor("#C0392B"))
c.setLineWidth(1.5)
c.circle(cx, cy, r, fill=True, stroke=True)
# Quadrant dividers
c.setStrokeColor(colors.HexColor("#888888"))
c.setLineWidth(0.8)
c.setDash(4, 2)
c.line(cx, cy - r, cx, cy + r) # vertical
c.line(cx - r, cy, cx + r, cy) # horizontal
c.setDash()
# Nipple/areola
c.setFillColor(colors.HexColor("#C9706A"))
c.setStrokeColor(colors.HexColor("#C0392B"))
c.setLineWidth(0.8)
c.circle(cx, cy, 10, fill=True, stroke=True)
c.setFillColor(colors.HexColor("#D4907A"))
c.circle(cx, cy, 5, fill=True, stroke=False)
# Quadrant labels
c.setFont('Helvetica-Bold', 8)
c.setFillColor(DARK)
c.drawCentredString(cx - 28, cy + 30, "UOQ")
c.setFont('Helvetica', 7)
c.setFillColor(ROSE)
c.drawCentredString(cx - 28, cy + 20, "50%")
c.setFont('Helvetica', 6.5)
c.setFillColor(MID)
c.drawCentredString(cx - 28, cy + 11, "most common")
c.setFont('Helvetica-Bold', 8)
c.setFillColor(DARK)
c.drawCentredString(cx + 28, cy + 30, "UIQ")
c.setFont('Helvetica', 7)
c.setFillColor(MID)
c.drawCentredString(cx + 28, cy + 20, "15%")
c.setFont('Helvetica-Bold', 8)
c.setFillColor(DARK)
c.drawCentredString(cx - 28, cy - 22, "LOQ")
c.setFont('Helvetica', 7)
c.setFillColor(MID)
c.drawCentredString(cx - 28, cy - 31, "10%")
c.setFont('Helvetica-Bold', 8)
c.setFillColor(DARK)
c.drawCentredString(cx + 28, cy - 22, "LIQ")
c.setFont('Helvetica', 7)
c.setFillColor(MID)
c.drawCentredString(cx + 28, cy - 31, "5%")
# Axillary tail arrow
c.setStrokeColor(TEAL)
c.setFillColor(TEAL)
c.setLineWidth(1.2)
# Draw arrow pointing upper-left-ish
ax1, ay1 = cx - 50, cy + 50
ax2, ay2 = cx - 62, cy + 62
c.line(ax1, ay1, ax2, ay2)
c.setFont('Helvetica-Bold', 7)
c.setFillColor(TEAL)
c.drawString(cx - 78, cy + 62, "Axillary")
c.drawString(cx - 78, cy + 54, "tail (20%)")
# Central (retroareolar) label
c.setFont('Helvetica', 6.5)
c.setFillColor(WHITE)
c.drawCentredString(cx, cy - 15, "Central")
# Labels outer
c.setFont('Helvetica-Bold', 7.5)
c.setFillColor(DARK)
c.drawCentredString(cx, cy + r + 10, "UPPER")
c.drawCentredString(cx, cy - r - 12, "LOWER")
c.drawString(cx - r - 15, cy - 3, "LATERAL")
c.drawString(cx + r + 3, cy - 3, "MEDIAL")
class PectoralisFixityDiagram(Flowable):
"""Diagram showing pectoralis fixity test"""
def __init__(self, width=260, height=140):
Flowable.__init__(self)
self.width = width
self.height = height
def draw(self):
c = self.canv
# Panel A - relaxed
self._draw_panel(c, 10, 10, 110, 120, relaxed=True, label="A) Muscle RELAXED")
# Panel B - contracted
self._draw_panel(c, 140, 10, 110, 120, relaxed=False, label="B) Muscle CONTRACTED")
def _draw_panel(self, c, x, y, w, h, relaxed, label):
# Background
c.setFillColor(LIGHT_GRAY)
c.setStrokeColor(BORDER_GRAY)
c.setLineWidth(0.5)
c.roundRect(x, y, w, h, 6, fill=True, stroke=True)
cx, cy = x + w/2, y + h/2
# Chest wall
c.setFillColor(colors.HexColor("#D5E8D4"))
c.setStrokeColor(colors.HexColor("#82B366"))
c.rect(x+8, y+20, w-16, 18, fill=True, stroke=True)
c.setFont('Helvetica', 7)
c.setFillColor(colors.HexColor("#2D6A4F"))
c.drawCentredString(cx, y+27, "Pectoralis Major")
# Breast outline
c.setFillColor(colors.HexColor("#FFE4E1"))
c.setStrokeColor(ROSE)
c.setLineWidth(1)
c.ellipse(cx-28, y+38, cx+28, y+85, fill=True, stroke=True)
# Tumour
c.setFillColor(colors.HexColor("#8B0000"))
c.circle(cx, y+62, 10, fill=True, stroke=False)
# Arrow showing mobility
if relaxed:
c.setStrokeColor(GREEN_OK)
c.setLineWidth(1.5)
c.line(cx-22, y+62, cx-36, y+62)
c.line(cx+22, y+62, cx+36, y+62)
# arrowheads
c.setFillColor(GREEN_OK)
c.polygon([cx-36, y+62, cx-30, y+65, cx-30, y+59], fill=True)
c.polygon([cx+36, y+62, cx+30, y+65, cx+30, y+59], fill=True)
c.setFont('Helvetica-Bold', 7.5)
c.setFillColor(GREEN_OK)
c.drawCentredString(cx, y+56, "Freely mobile")
else:
c.setStrokeColor(RED_FLAG)
c.setLineWidth(1.5)
# X marks
c.line(cx-20, y+52, cx-8, y+45)
c.line(cx-8, y+52, cx-20, y+45)
c.line(cx+8, y+52, cx+20, y+45)
c.line(cx+20, y+52, cx+8, y+45)
c.setFont('Helvetica-Bold', 7.5)
c.setFillColor(RED_FLAG)
c.drawCentredString(cx, y+38, "Restricted = FIXED")
# Label
c.setFont('Helvetica-Bold', 8)
c.setFillColor(DARK)
c.drawCentredString(cx, y + h - 10, label)
class SkinFixityDiagram(Flowable):
"""Tethering vs fixation"""
def __init__(self, width=260, height=130):
Flowable.__init__(self)
self.width = width
self.height = height
def draw(self):
c = self.canv
self._panel(c, 5, 5, 115, 120, tethered=True)
self._panel(c, 140, 5, 115, 120, tethered=False)
def _panel(self, c, x, y, w, h, tethered):
c.setFillColor(LIGHT_GRAY)
c.setStrokeColor(BORDER_GRAY)
c.setLineWidth(0.5)
c.roundRect(x, y, w, h, 6, fill=True, stroke=True)
cx = x + w/2
# Skin layer
skin_y = y + h - 22
c.setFillColor(colors.HexColor("#F5CBA7"))
c.setStrokeColor(colors.HexColor("#E59866"))
c.setLineWidth(0.8)
c.rect(x+8, skin_y, w-16, 12, fill=True, stroke=True)
c.setFont('Helvetica', 6.5)
c.setFillColor(DARK)
c.drawCentredString(cx, skin_y+4, "Skin")
# Breast tissue
c.setFillColor(colors.HexColor("#FFE4E1"))
c.setStrokeColor(ROSE)
c.setLineWidth(0.8)
c.rect(x+8, y+30, w-16, skin_y - y - 32, fill=True, stroke=True)
# Tumour
tum_y = y + 55
c.setFillColor(colors.HexColor("#8B0000"))
c.circle(cx, tum_y, 11, fill=True, stroke=False)
if tethered:
# Cooper's ligaments - normal to tethered
c.setStrokeColor(colors.HexColor("#884EA0"))
c.setLineWidth(0.8)
# Ligament lines pulling skin
for ox in [-12, -5, 0, 5, 12]:
c.line(cx + ox*0.5, tum_y - 11, cx + ox, skin_y)
# Skin dimple
c.setFillColor(colors.HexColor("#F5CBA7"))
c.setStrokeColor(colors.HexColor("#E59866"))
# draw a small inward dimple in skin
c.bezier(cx-10, skin_y, cx-3, skin_y-5, cx+3, skin_y-5, cx+10, skin_y)
c.setFont('Helvetica-Bold', 8)
c.setFillColor(colors.HexColor("#884EA0"))
c.drawCentredString(cx, y+18, "TETHERING")
c.setFont('Helvetica', 6.5)
c.setFillColor(DARK)
c.drawCentredString(cx, y+10, "Cooper's lig. infiltrated")
else:
# Fixed - direct connection
c.setFillColor(colors.HexColor("#8B0000"))
c.rect(cx-4, tum_y+10, 8, skin_y - tum_y - 10, fill=True, stroke=False)
c.setFont('Helvetica-Bold', 8)
c.setFillColor(RED_FLAG)
c.drawCentredString(cx, y+18, "FIXATION")
c.setFont('Helvetica', 6.5)
c.setFillColor(DARK)
c.drawCentredString(cx, y+10, "Direct skin infiltration")
class InspectionPositionsDiagram(Flowable):
"""Four inspection positions as stick figures"""
def __init__(self, width=460, height=110):
Flowable.__init__(self)
self.width = width
self.height = height
def _stick_figure(self, c, x, y, arms='down', label=''):
# Head
c.setFillColor(colors.HexColor("#FAD7A0"))
c.setStrokeColor(DARK)
c.setLineWidth(0.8)
c.circle(x, y+85, 10, fill=True, stroke=True)
# Body
c.setStrokeColor(DARK)
c.setLineWidth(1.2)
c.line(x, y+75, x, y+40) # torso
# Legs
c.line(x, y+40, x-12, y+10)
c.line(x, y+40, x+12, y+10)
if arms == 'down':
c.line(x, y+68, x-18, y+50)
c.line(x, y+68, x+18, y+50)
elif arms == 'up':
c.line(x, y+68, x-15, y+88)
c.line(x, y+68, x+15, y+88)
elif arms == 'hip':
c.line(x, y+68, x-20, y+60)
c.line(x-20, y+60, x-14, y+52)
c.line(x, y+68, x+20, y+60)
c.line(x+20, y+60, x+14, y+52)
elif arms == 'forward':
c.line(x, y+68, x-16, y+55)
c.line(x, y+68, x+16, y+55)
# tilt body
# Label
c.setFont('Helvetica-Bold', 7.5)
c.setFillColor(TEAL)
c.drawCentredString(x, y, label)
def draw(self):
c = self.canv
positions = [
(55, 5, 'down', 'Pos 1: Arms\nat rest'),
(170, 5, 'up', 'Pos 2: Arms\nraised'),
(285, 5, 'hip', 'Pos 3: Hands\non hips'),
(400, 5, 'forward', 'Pos 4: Lean\nforward'),
]
nums = ['1', '2', '3', '4']
for (x, y, arms, label), num in zip(positions, nums):
# Box
c.setFillColor(TEAL_LIGHT)
c.setStrokeColor(TEAL_MED)
c.setLineWidth(0.5)
c.roundRect(x-45, y, 88, 105, 6, fill=True, stroke=True)
# Number badge
c.setFillColor(TEAL)
c.circle(x-30, y+96, 9, fill=True, stroke=False)
c.setFont('Helvetica-Bold', 9)
c.setFillColor(WHITE)
c.drawCentredString(x-30, y+93, num)
self._stick_figure(c, x, y+3, arms=arms, label='')
# label below
c.setFont('Helvetica-Bold', 7.5)
c.setFillColor(TEAL)
for i, line in enumerate(label.split('\n')):
c.drawCentredString(x, y + 3 + i*9, line)
# ─── Content builder ─────────────────────────────────────────────────────────
def section_header(text, color=TEAL):
d = Drawing(460, 22)
d.add(Rect(0, 0, 460, 22, fillColor=color, strokeColor=None))
d.add(String(10, 6, text, fontName='Helvetica-Bold', fontSize=12, fillColor=WHITE))
return d
def tip_box(text, bg=TEAL_LIGHT, border=TEAL):
data = [[Paragraph(text, tip_style)]]
t = Table(data, colWidths=[440])
t.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,-1), bg),
('BOX', (0,0), (-1,-1), 1, border),
('LEFTPADDING', (0,0), (-1,-1), 8),
('RIGHTPADDING', (0,0), (-1,-1), 8),
('TOPPADDING', (0,0), (-1,-1), 5),
('BOTTOMPADDING', (0,0), (-1,-1), 5),
('ROUNDEDCORNERS', (0,0), (-1,-1), [4,4,4,4]),
]))
return t
def warning_box(text):
return tip_box(text, bg=ROSE_LIGHT, border=ROSE)
def gold_box(text):
return tip_box(text, bg=GOLD_LIGHT, border=GOLD)
def make_table(headers, rows, col_widths):
data = [headers] + rows
t = Table(data, colWidths=col_widths)
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,0), 8.5),
('FONTNAME', (0,1), (-1,-1), 'Helvetica'),
('FONTSIZE', (0,1), (-1,-1), 8),
('ROWBACKGROUNDS', (0,1), (-1,-1), [WHITE, LIGHT_GRAY]),
('BOX', (0,0), (-1,-1), 0.8, BORDER_GRAY),
('INNERGRID', (0,0), (-1,-1), 0.4, BORDER_GRAY),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('TOPPADDING', (0,0), (-1,-1), 3),
('BOTTOMPADDING', (0,0), (-1,-1), 3),
('LEFTPADDING', (0,0), (-1,-1), 5),
])
t.setStyle(style)
return t
def bp(text):
return Paragraph(f"<bullet>•</bullet> {text}", bullet)
def sp(n=4):
return Spacer(1, n)
# ─── Cover page ───────────────────────────────────────────────────────────────
def cover_page():
elems = []
elems.append(Spacer(1, 2*cm))
# Main title block
d = Drawing(460, 120)
d.add(Rect(0, 0, 460, 120, fillColor=TEAL, strokeColor=None, rx=10, ry=10))
d.add(Rect(0, 0, 460, 4, fillColor=GOLD, strokeColor=None))
d.add(Rect(0, 116, 460, 4, fillColor=GOLD, strokeColor=None))
d.add(String(230, 78, "BREAST EXAMINATION", textAnchor='middle',
fontName='Helvetica-Bold', fontSize=24, fillColor=WHITE))
d.add(String(230, 52, "Surgical Viva Guide", textAnchor='middle',
fontName='Helvetica', fontSize=15, fillColor=TEAL_LIGHT))
d.add(String(230, 28, "History • Inspection • Palpation • Special Tests • Differentials",
textAnchor='middle', fontName='Helvetica', fontSize=9, fillColor=TEAL_MED))
elems.append(d)
elems.append(sp(12))
# Subtitle box
sub = Table([[Paragraph("Complete clinical approach to the breast for MBBS / MS Surgery viva examinations", subtitle_style)]],
colWidths=[440])
sub.setStyle(TableStyle([('BACKGROUND',(0,0),(-1,-1),TEAL_LIGHT),
('BOX',(0,0),(-1,-1),1,TEAL),
('TOPPADDING',(0,0),(-1,-1),8),
('BOTTOMPADDING',(0,0),(-1,-1),8)]))
elems.append(sub)
elems.append(sp(20))
# Contents box
contents = [
["01", "History Taking", "Patient details, lump, pain, discharge, risk factors"],
["02", "Inspection", "4 positions, skin signs, nipple changes"],
["03", "Palpation", "14-point lump assessment, fixity tests"],
["04", "Special Tests", "Pectoralis, serratus, chest wall, transillumination"],
["05", "Axilla Examination", "Node groups, technique"],
["06", "Differential Diagnosis", "Fibroadenoma vs carcinoma vs cyst vs fibroadenosis"],
["07", "Malignant Features", "Red flag signs on examination"],
["08", "Investigations", "Triple assessment, mammography, FNAC, core biopsy"],
["09", "Staging & Classification", "TNM, clinical stages"],
["10", "High-Yield Viva Q&A", "Classic viva traps and answers"],
]
tbl = Table(
[["#", "Section", "Contents"]] + contents,
colWidths=[28, 130, 282]
)
tbl.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), DARK),
('TEXTCOLOR', (0,0), (-1,0), WHITE),
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,0), 9),
('BACKGROUND', (0,1), (0,-1), TEAL),
('TEXTCOLOR', (0,1), (0,-1), WHITE),
('FONTNAME', (0,1), (0,-1), 'Helvetica-Bold'),
('FONTSIZE', (0,1), (0,-1), 9),
('FONTNAME', (1,1), (1,-1), 'Helvetica-Bold'),
('FONTSIZE', (1,1), (1,-1), 8.5),
('FONTNAME', (2,1), (2,-1), 'Helvetica'),
('FONTSIZE', (2,1), (2,-1), 8),
('ROWBACKGROUNDS', (1,1), (-1,-1), [WHITE, LIGHT_GRAY]),
('BOX', (0,0), (-1,-1), 0.8, BORDER_GRAY),
('INNERGRID', (0,0), (-1,-1), 0.3, BORDER_GRAY),
('TOPPADDING', (0,0), (-1,-1), 4),
('BOTTOMPADDING', (0,0), (-1,-1), 4),
('LEFTPADDING', (0,0), (-1,-1), 6),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
]))
elems.append(tbl)
elems.append(sp(16))
# Source
src = Paragraph("<i>Sources: S Das - A Manual on Clinical Surgery (13th ed.) | Bailey & Love's Short Practice of Surgery (28th ed.) | Schwartz's Principles of Surgery (11th ed.)</i>", small)
elems.append(src)
elems.append(PageBreak())
return elems
# ─── Page 2: History + Inspection ────────────────────────────────────────────
def page_history_inspection():
elems = []
# Section 1
elems.append(section_header("01 HISTORY TAKING"))
elems.append(sp(6))
elems.append(Paragraph("Key Demographic Factors", h2))
age_data = [
[Paragraph("<b>Age Group</b>", small), Paragraph("<b>Most Likely Diagnosis</b>", small)],
["< 35 years", "Fibroadenoma (most likely)"],
["20 - 40 years", "Fibroadenosis (mammary dysplasia)"],
["> 40 years", "Carcinoma must always be excluded"],
["Lactating woman (any age)", "Mastitis / breast abscess"],
["Male patient", "Gynaecomastia; rarely carcinoma"],
]
elems.append(make_table(age_data[0:1], age_data[1:], [120, 320]))
elems.append(sp(6))
elems.append(Paragraph("Chief Complaint - Lump", h2))
elems.append(bp("<b>Duration & onset:</b> Long history + slow growth = benign; Short history + fast growth = malignant"))
elems.append(bp("<b>Sudden enlargement</b> into a lump = haemorrhage into a cyst or nodule"))
elems.append(bp("Average time from patient discovering lump to surgeon = <b>~6 weeks</b> in carcinoma"))
elems.append(sp(4))
elems.append(warning_box("⚠ VIVA TRAP: Carcinoma of the breast is PAINLESS at onset. A painless lump accidentally discovered during washing must NEVER be ignored - it is carcinoma until proven otherwise."))
elems.append(sp(6))
elems.append(Paragraph("Pain (Mastalgia)", h2))
pain_rows = [
["Throbbing, severe", "Acute mastitis / breast abscess (pus formation)"],
["Cyclical, premenstrual", "Fibroadenosis - most common benign cause"],
["Non-cyclical, localised", "Periductal mastitis, fat necrosis, musculoskeletal"],
["Back / hip / shoulder", "Bony metastases from carcinoma (late feature)"],
["Painless lump", "MUST rule out carcinoma"],
]
elems.append(make_table(
[Paragraph("<b>Pain Character</b>", small), Paragraph("<b>Likely Diagnosis</b>", small)],
pain_rows, [160, 280]))
elems.append(sp(6))
elems.append(Paragraph("Nipple Discharge", h2))
disc_rows = [
["Bright red / blood", "Duct papilloma (most common), duct carcinoma"],
["Serous / clear", "Early duct papilloma, carcinoma"],
["Green / brown", "Fibroadenosis, duct ectasia"],
["White (milky)", "Galactorrhoea - prolactinoma, drugs (phenothiazines, metoclopramide)"],
["Purulent", "Abscess, infected duct ectasia"],
]
elems.append(make_table(
[Paragraph("<b>Colour</b>", small), Paragraph("<b>Diagnosis</b>", small)],
disc_rows, [130, 310]))
elems.append(sp(6))
elems.append(Paragraph("Risk Factors for Breast Carcinoma", h2))
risk_rows = [
["Family history", "First-degree relative (mother/sister); BRCA1 / BRCA2 mutation"],
["Age", "> 40 years; incidence increases with age"],
["Reproductive", "Early menarche (<12 yrs); late menopause (>55 yrs); nulliparity; first child >30 yrs"],
["Hormonal", "HRT (long-term); OCP (slight risk); obesity (↑ oestrogen post-menopause)"],
["Previous disease", "Contralateral breast Ca; atypical ductal hyperplasia; LCIS"],
["Radiation", "Chest wall irradiation in childhood (e.g. for lymphoma)"],
["Lifestyle", "Alcohol; diet rich in saturated fat; sedentary"],
["Male factors", "Klinefelter syndrome; oestrogen therapy; liver disease"],
]
elems.append(make_table(
[Paragraph("<b>Risk Factor</b>", small), Paragraph("<b>Detail</b>", small)],
risk_rows, [120, 320]))
elems.append(sp(6))
elems.append(gold_box("✔ MNEMONIC - Risk factors: FEMALE BRAS → Family history | Early menarche | Menopause late | Age >40 | Late 1st pregnancy | Exposure to radiation | Body weight/obesity | Race/HRT | Alcohol | Sedentary"))
elems.append(sp(8))
# Section 2
elems.append(section_header("02 INSPECTION"))
elems.append(sp(6))
elems.append(Paragraph("Setup & Patient Position", h2))
elems.append(bp("Patient <b>seated</b>, fully exposed to nipple level; adequate privacy + chaperone"))
elems.append(bp("Adequate lighting; examine from in front and then from the side"))
elems.append(bp("Examine the <b>normal breast first</b> to establish baseline"))
elems.append(sp(6))
elems.append(Paragraph("Four Inspection Positions", h2))
elems.append(InspectionPositionsDiagram(width=460, height=110))
elems.append(sp(4))
pos_rows = [
["1. Arms at rest (sides)", "Symmetry, contour, skin changes, nipple position"],
["2. Arms raised above head", "Lower surfaces visible; reveals hidden lumps, puckering, axillae"],
["3. Hands pressed on hips", "Tenses pectoralis; accentuates tethering / dimpling"],
["4. Leaning forward", "Pendulous breasts fall; reveals undersurface and asymmetry"],
]
elems.append(make_table(
[Paragraph("<b>Position</b>", small), Paragraph("<b>What it Reveals</b>", small)],
pos_rows, [170, 270]))
elems.append(sp(6))
elems.append(Paragraph("Skin Signs - What to Look For", h2))
skin_rows = [
["Peau d'orange",
"Oedema of skin with deepening of sweat gland/hair follicle openings (orange-peel). Caused by blockage of subcuticular lymphatics by carcinoma cells."],
["Dimpling / puckering",
"Infiltration and shortening of Cooper's ligaments by carcinoma pulling the skin inwards."],
["Dilated superficial veins",
"Cystosarcoma phylloides, rapidly growing sarcoma, acute lacteal duct obstruction."],
["Nipple retraction (recent)",
"Carcinoma extending along lactiferous ducts → fibrosis pulling nipple inward. Lifelong inversion = normal variant."],
["Nipple eczema / scaling",
"Paget's disease of nipple → always biopsy to exclude underlying DCIS or invasive Ca."],
["Ulceration / fungation",
"Advanced carcinoma, Stage IV."],
["Satellite skin nodules",
"Metastatic deposits in skin from underlying carcinoma."],
["Redness + warmth",
"Mastitis; inflammatory (mastitis) carcinoma - do NOT treat as simple mastitis without biopsy."],
]
elems.append(make_table(
[Paragraph("<b>Sign</b>", small), Paragraph("<b>Significance & Mechanism</b>", small)],
skin_rows, [120, 320]))
elems.append(PageBreak())
return elems
# ─── Page 3: Palpation ────────────────────────────────────────────────────────
def page_palpation():
elems = []
elems.append(section_header("03 PALPATION"))
elems.append(sp(6))
# Quadrant diagram + technique
elems.append(Paragraph("Breast Quadrants & Cancer Distribution", h2))
quad_table = Table(
[[BreastQuadrantDiagram(180, 180),
[
Paragraph("Palpation Technique", h3),
bp("Position: <b>Sitting → Semi-recumbent (45°) → Supine</b>"),
bp("Place small pillow under ipsilateral scapula (prevents breast falling laterally)"),
bp("Use <b>palmar surface of fingers, hand flat</b> (NOT palm, NOT fingertips)"),
bp("Palpate between pulps of fingers and thumb for a lump"),
sp(4),
Paragraph("Systematic Quadrant Check", h3),
bp("Upper outer quadrant (UOQ) - 50% of cancers here"),
bp("Upper inner quadrant (UIQ)"),
bp("Lower outer quadrant (LOQ)"),
bp("Lower inner quadrant (LIQ)"),
bp("<b>Retroareolar region</b> (just behind nipple - often missed!)"),
bp("Axillary tail of Spence"),
]]],
colWidths=[190, 260])
quad_table.setStyle(TableStyle([
('VALIGN', (0,0), (-1,-1), 'TOP'),
('LEFTPADDING', (0,0), (-1,-1), 4),
]))
elems.append(quad_table)
elems.append(sp(6))
elems.append(Paragraph("14-Point Lump Assessment (S Das)", h2))
palpation_rows = [
["1. Site", "Quadrant + distance from nipple in cm"],
["2. Size & Shape", "Measure in cm; globular (fibroadenoma) vs irregular (carcinoma)"],
["3. Number", "Solitary (Ca, fibroadenoma, cyst) vs multiple bilateral (fibroadenosis)"],
["4. Surface", "Smooth (fibroadenoma, cyst) vs irregular/nodular (carcinoma)"],
["5. Edge / Margin", "Well-defined (fibroadenoma) vs ill-defined / spiculated (carcinoma)"],
["6. Consistency", "Cystic / firm / stony hard (see table below)"],
["7. Fluctuation", "Positive in cysts and abscess; tense cyst may be negative"],
["8. Transillumination", "Cyst = translucent; solid tumour = opaque; fat = translucent"],
["9. Tenderness", "Mastitis, abscess; absent in carcinoma"],
["10. Fixity to skin", "Tethering (Cooper's lig.) vs Fixed (direct infiltration)"],
["11. Fixity to breast tissue", "Fibroadenoma = freely mobile (Breast Mouse); Carcinoma = fixed"],
["12. Fixity to pectoralis major", "Hip-press test (see diagram below)"],
["13. Fixity to serratus anterior", "Push-against-wall test for lower outer quadrant lumps"],
["14. Fixity to chest wall", "Fixed irrespective of ALL muscle contractions = T4 disease"],
]
elems.append(make_table(
[Paragraph("<b>#</b>", small), Paragraph("<b>Assessment</b>", small)],
[[r[0], Paragraph(r[1], small)] for r in palpation_rows],
[170, 270]))
elems.append(sp(6))
elems.append(Paragraph("Consistency - Diagnostic Guide", h2))
consist_rows = [
["Soft, cystic, fluctuant", "Breast cyst, abscess"],
["Firm, diffuse India-rubber feel", "Fibroadenosis (mammary dysplasia)"],
["Firm, well-encapsulated, smooth", "Fibroadenoma"],
["Stony hard, irregular", "Carcinoma (scirrhous) ← most important"],
["Variable; huge mass", "Cystosarcoma phylloides / Sarcoma"],
["Rubbery, diffuse", "Hashimoto's thyroiditis (if neck) / lymphoma"],
]
elems.append(make_table(
[Paragraph("<b>Consistency</b>", small), Paragraph("<b>Diagnosis</b>", small)],
consist_rows, [180, 260]))
elems.append(sp(6))
elems.append(Paragraph("Tethering vs Fixation to Skin", h2))
elems.append(SkinFixityDiagram(width=260, height=130))
elems.append(sp(4))
skin_diff = [
["Tethering", "Cooper's ligaments infiltrated; lump moves but skin dimples at extremes",
"T2 stage; moveable lump"],
["Fixation", "Direct infiltration of skin; lump and skin move together; skin cannot be pinched",
"T4 stage; sign of advanced disease"],
]
elems.append(make_table(
[Paragraph("<b>Type</b>", small), Paragraph("<b>Mechanism</b>", small), Paragraph("<b>Stage</b>", small)],
skin_diff, [90, 240, 110]))
elems.append(sp(6))
elems.append(Paragraph("Pectoralis Major Fixity Test (Hip-Press Test)", h2))
elems.append(PectoralisFixityDiagram(width=260, height=140))
elems.append(sp(4))
elems.append(tip_box("Method: (1) Ask patient to place hand on hip LIGHTLY (muscle relaxed) → assess lump mobility in direction of muscle fibres and at right angles. (2) Now ask her to press hip AS HARD AS POSSIBLE (muscle taut) → reassess. Restricted mobility in line of fibres = fixity to pectoralis major / pectoral fascia."))
elems.append(sp(6))
elems.append(Paragraph("Nipple Palpation (Never Skip This!)", h2))
elems.append(bp("Always palpate tissue just <b>behind the nipple</b> - tumours here are easily missed"))
elems.append(bp("Move the retro-areolar lump - watch if this causes or increases <b>nipple retraction</b>"))
elems.append(bp("Gently press: any <b>discharge from a single duct</b> = duct papilloma; from multiple ducts = fibroadenosis"))
elems.append(bp("Note discharge colour (see discharge table in history section)"))
elems.append(warning_box("⚠ Any tumour deep to the nipple will be fixed to the nipple, WHETHER IT IS BENIGN OR MALIGNANT, because the main mammary ducts pass through it. Do NOT automatically call this malignant!"))
elems.append(PageBreak())
return elems
# ─── Page 4: Axilla + DD + Malignant Features ────────────────────────────────
def page_axilla_dd():
elems = []
elems.append(section_header("04 AXILLA EXAMINATION"))
elems.append(sp(6))
elems.append(Paragraph("Technique", h2))
elems.append(bp("<b>Support the patient's arm</b> with your ipsilateral hand to relax the pectoralis and allow deep axillary palpation"))
elems.append(bp("Use the <b>other hand</b> to palpate deeply into the axilla"))
elems.append(bp("Assess each node group systematically"))
elems.append(sp(6))
axilla_rows = [
["Anterior (pectoral)", "Along lower border of pectoralis minor", "Level I; first group involved in breast Ca"],
["Central", "Centre of axilla against chest wall", "Most palpable group; Level II"],
["Posterior (subscapular)", "Along posterior axillary fold", "Level I"],
["Lateral (brachial)", "Along axillary vein", "Level I"],
["Apical (infraclavicular)", "Behind clavicle / pectoralis minor", "Level III; indicates advanced disease"],
["Supraclavicular", "Angle between SCM and clavicle", "N3 disease; stage IIIC / IV"],
]
elems.append(make_table(
[Paragraph("<b>Node Group</b>", small), Paragraph("<b>Location</b>", small), Paragraph("<b>Significance</b>", small)],
axilla_rows, [120, 170, 150]))
elems.append(sp(4))
elems.append(tip_box("Always examine BOTH sides - including opposite breast and axilla. Bilateral involvement occurs in ~5% of primary breast cancers."))
elems.append(sp(8))
elems.append(section_header("05 DIFFERENTIAL DIAGNOSIS OF A BREAST LUMP"))
elems.append(sp(6))
dd_rows = [
["Fibroadenoma", "15-35", "Firm, smooth, well-defined", "Highly mobile - 'breast mouse'", "No", "Normal", "No nodes"],
["Breast cyst", "30-55", "Cystic / fluctuant", "Mobile", "No", "Normal", "Refills; transilluminates"],
["Fibroadenosis", "20-50", "Firm, diffuse, nodular", "Diffuse bilateral", "Yes (cyclical)", "Normal", "Multiple lumps both sides"],
["Scirrhous Carcinoma", ">40", "Stony hard, irregular", "Fixed to breast + skin", "No (painless)", "Peau d'orange, dimpling", "Hard axillary nodes"],
["Breast abscess", "Lactating", "Fluctuant, tense", "Fixed (inflamed)", "Very tender", "Red, warm, oedema", "Fever, raised WBC"],
["Fat necrosis", "Any (trauma)", "Hard, irregular", "May be fixed", "±", "Skin tethering", "History of trauma"],
["Cystosarcoma phylloides", ">40", "Variable; huge", "Initially mobile", "No", "Dilated veins; no fixation", "Rapidly growing"],
["Duct papilloma", ">30", "Cystic (retroareolar)", "Mobile", "No", "Bloody nipple discharge", "Premalignant"],
["Paget's disease", ">50", "Underlying mass ± visible", "Depends", "No", "Nipple eczema/scaling", "Biopsy nipple"],
["Gynaecomastia (M)", "Any", "Firm disc, subareolar", "Central", "Tender", "Bilateral", "Exclude hypogonadism"],
]
elems.append(make_table(
[Paragraph(h, small) for h in ["Condition","Age","Consistency","Mobility","Pain","Skin/Nipple","Other"]],
[[Paragraph(c if i != 0 else f"<b>{c}</b>", small) for i, c in enumerate(r)] for r in dd_rows],
[85, 38, 82, 72, 40, 90, 80]))
elems.append(sp(8))
elems.append(section_header("06 FEATURES SUGGESTING MALIGNANCY", color=ROSE))
elems.append(sp(6))
elems.append(Paragraph("Red Flag Signs on Examination", h2))
mal_rows = [
["Stony hard consistency", "Hallmark of scirrhous/invasive ductal carcinoma; also calcification"],
["Irregular, ill-defined margin", "Spiculated lesion = carcinoma until proven otherwise"],
["Fixed to breast tissue", "Cannot be moved within breast = invasive carcinoma"],
["Skin tethering / dimpling", "Infiltration of Cooper's ligaments"],
["Peau d'orange", "Dermal lymphatic blockage by carcinoma"],
["Nipple retraction (recent)", "Extension along ducts with fibrosis"],
["Nipple ulceration / eczema", "Paget's disease = underlying DCIS/invasive Ca"],
["Fixed to pectoral muscle/chest wall", "T3/T4 stage; advanced disease"],
["Hard, matted axillary nodes", "Nodal metastases; worst if fixed (N2)"],
["Supraclavicular lymphadenopathy", "N3 stage; indicates widespread disease"],
["Dilated chest wall veins", "Obstruction of deep veins by tumour"],
["Satellite skin nodules", "Cutaneous metastases; cancer en cuirasse"],
["Brawny arm oedema", "Axillary vein obstruction by nodes / post-surgery"],
["Male patient with hard nodule", "Carcinoma in male (rare but occurs)"],
]
tbl = make_table(
[Paragraph("<b>Sign</b>", small), Paragraph("<b>Significance</b>", small)],
mal_rows, [170, 270])
# Highlight all cells red tinge
for i in range(1, len(mal_rows)+1):
tbl.setStyle(TableStyle([('BACKGROUND', (0, i), (0, i), ROSE_LIGHT)]))
elems.append(tbl)
elems.append(sp(4))
elems.append(warning_box("⚠ VIVA TRAP: A YOUNG patient, NO skin fixation, NO lymph node enlargement → STILL DO NOT exclude carcinoma. These classical signs may be absent in early disease. Carcinoma should not be excluded on the basis of young age or absence of nodes alone."))
elems.append(PageBreak())
return elems
# ─── Page 5: Investigations + Staging + Q&A ──────────────────────────────────
def page_investigations_staging():
elems = []
elems.append(section_header("07 INVESTIGATIONS - TRIPLE ASSESSMENT"))
elems.append(sp(6))
elems.append(gold_box("TRIPLE ASSESSMENT = Gold Standard for any breast lump.\n1. Clinical examination | 2. Imaging (mammogram ± USG) | 3. Tissue sampling (FNAC or core biopsy)\nAll three results MUST be concordant. If ANY ONE is suspicious → proceed to surgery."))
elems.append(sp(6))
inv_rows = [
["Mammography", ">35-40 yrs; 2 views (CC + MLO); features: spiculated mass, pleomorphic calcification, skin thickening", "First-line imaging for older women"],
["Ultrasound (USG)", "<35 yrs (dense breasts); distinguishes solid vs cystic; guides FNAC/core biopsy", "First-line for younger women"],
["MRI Breast", "BRCA carriers; lobular Ca extent; occult primary; implants; pre-BCS planning", "Not routine"],
["FNAC", "22-25G needle; cytology only; C1-C5 reporting; quick + cheap", "Cannot distinguish invasive from in-situ"],
["Core Needle Biopsy (Tru-cut)", "14-16G; full histology; ER/PR/HER2 status; invasive vs in-situ", "Preferred for preoperative planning"],
["Chest X-ray", "Lung metastases; pleural effusion", "Staging"],
["CT chest/abdomen/pelvis", "Full staging in confirmed cancer", "Staging"],
["Bone scan", "Bone metastases; indicated if bone pain or raised ALP", "Staging"],
["Serum CA 15-3, CEA", "Tumour markers; monitor treatment response, NOT diagnosis", "Follow-up"],
["ER / PR / HER2", "On core biopsy specimen; determines systemic treatment", "Receptor status"],
]
elems.append(make_table(
[Paragraph(h, small) for h in ["Investigation", "Details", "When / Note"]],
inv_rows, [115, 215, 110]))
elems.append(sp(6))
elems.append(Paragraph("FNAC Reporting - C-Classification", h2))
fnac_rows = [
["C1", "Non-diagnostic / inadequate", "Repeat FNAC", "—"],
["C2", "Benign", "Observe / clinical follow-up", "<3%"],
["C3", "Atypical / uncertain", "Repeat or core biopsy", "~10-20%"],
["C4", "Suspicious for malignancy", "Core biopsy / proceed", "60-75%"],
["C5", "Malignant", "Plan definitive surgery", ">97%"],
]
elems.append(make_table(
[Paragraph(h, small) for h in ["Class", "Cytology", "Action", "Malignancy Risk"]],
fnac_rows, [45, 150, 170, 95]))
elems.append(sp(4))
elems.append(tip_box("Note: FNAC CANNOT distinguish follicular adenoma from carcinoma in the THYROID, but for BREAST, FNAC CAN suggest malignancy. However, it cannot tell invasive from in-situ cancer - core biopsy is needed for that."))
elems.append(sp(8))
elems.append(section_header("08 STAGING OF BREAST CANCER - TNM"))
elems.append(sp(6))
tnm_rows = [
["T1", "≤2 cm; no fixation; no nipple retraction"],
["T2", "2-5 cm; skin may be tethered; no pectoral fixation"],
["T3", ">5 cm; skin fixed / ulcerated; pectoral fixation (but not chest wall)"],
["T4", "Chest wall fixation; peau d'orange (large area); inflammatory carcinoma"],
["N0", "No palpable ipsilateral axillary nodes"],
["N1", "Mobile ipsilateral axillary nodes (involved)"],
["N2", "Fixed axillary nodes"],
["N3", "Supraclavicular / infraclavicular nodes; arm oedema"],
["M0", "No distant metastasis"],
["M1", "Distant metastasis (bones, lung, liver, brain, ovary)"],
]
tbl = make_table(
[Paragraph("<b>Stage</b>", small), Paragraph("<b>Description</b>", small)],
tnm_rows, [60, 380])
for i, row in enumerate(tnm_rows):
bg = TEAL_LIGHT if row[0].startswith('T') else (ROSE_LIGHT if row[0].startswith('N') else GOLD_LIGHT)
tbl.setStyle(TableStyle([('BACKGROUND', (0, i+1), (0, i+1), bg)]))
elems.append(tbl)
elems.append(sp(4))
elems.append(Paragraph("Metastatic Spread from Breast Cancer", h2))
elems.append(make_table(
[Paragraph("<b>Route</b>", small), Paragraph("<b>Sites</b>", small)],
[
["Lymphatic", "Axillary nodes (most common) → supraclavicular → internal mammary nodes"],
["Blood-borne", "Bones (most common: spine, pelvis, femur, ribs) → Lung → Liver → Brain"],
["Transcoelomic", "Ovary (Krukenberg tumour)"],
], [90, 350]))
elems.append(sp(8))
elems.append(section_header("09 HIGH-YIELD VIVA Q&A"))
elems.append(sp(6))
qas = [
("Most common site of carcinoma in the breast?",
"Upper outer quadrant (UOQ) - ~50% of all carcinomas. It contains the greatest volume of breast tissue including the axillary tail."),
("What is 'peau d'orange' and its mechanism?",
"Orange-peel appearance of breast skin. Caused by blockage of subcuticular lymphatics by cancer cells → skin oedema. Hair follicle openings are tethered down and cannot expand → pitting appearance."),
("What is Cooper's ligament and what happens when invaded?",
"Fibrous suspensory septae from breast glandular tissue to overlying skin. Infiltration by carcinoma → ligaments shorten and become inelastic → skin dimpling, puckering, or retraction."),
("What is a 'breast mouse'?",
"A fibroadenoma - highly mobile within the breast, not fixed to skin or deeper structures, slips away from fingers on palpation. Occurs in young women (15-35 yrs)."),
("What is Paget's disease of the nipple?",
"Eczema-like change (scaling, weeping, crusting) of nipple + areola representing extension of underlying DCIS or invasive carcinoma through lactiferous ducts to nipple epidermis. ALWAYS biopsy nipple eczema in women >40 yrs."),
("Carcinoma is painless - always true?",
"PAINLESS AT ONSET - all neoplasms (benign or malignant) are painless initially. Late-stage carcinoma causes bone pain (metastases). Inflammatory carcinoma is a painful exception. Pain must NEVER be used to exclude carcinoma."),
("What is the difference between tethering and fixation to skin?",
"Tethering: Cooper's ligaments invaded; lump still moves a little but skin dimples at extremes (T2). Fixation: direct skin infiltration by tumour; lump and skin move as one unit; skin cannot be pinched up (T4)."),
("Fixity to pectoralis major - how tested?",
"Hip-press test: patient presses hand lightly on hip (relaxed) → test mobility. Then presses AS HARD AS POSSIBLE (taut) → reassess. Restricted movement in the line of muscle fibres = fixed to pectoralis major."),
("Most common type of breast cancer?",
"Invasive Ductal Carcinoma (IDC) / Scirrhous carcinoma - ~75-80% of all breast cancers. Presents as stony hard, irregular, spiculated mass."),
("Which breast cancer has the WORST prognosis?",
"Inflammatory carcinoma (mastitis carcinomatosa) - entire breast red, hot, oedematous; no discrete lump; median survival poor. Also anaplastic (of thyroid equivalent = rapidly fatal); in breast, inflammatory Ca is worst."),
("What is Modified Radical Mastectomy (MRM)?",
"Removal of entire breast + all axillary lymph nodes, preserving BOTH pectoralis major AND minor. This is the standard surgery for breast cancer today, replacing Halsted's radical mastectomy (which removed pectorals). Equal outcomes, less morbidity."),
("What is the Sentinel Lymph Node Biopsy (SLNB)?",
"Injection of radioactive tracer/blue dye near the tumour. First draining (sentinel) node identified and biopsied. If SLNB negative = no further axillary surgery needed. If positive = full axillary dissection."),
]
for q, a in qas:
row = Table(
[[Paragraph(f"<b>Q: {q}</b>", Paragraph('', styles['Normal']).__class__.__new__(ParagraphStyle, 'QStyle', fontName='Helvetica-Bold', fontSize=8.5, textColor=TEAL)),
Paragraph(f"A: {a}", Paragraph('', styles['Normal']).__class__.__new__(ParagraphStyle, 'AStyle', fontName='Helvetica', fontSize=8.5, textColor=DARK))]],
colWidths=[440]
)
q_para = Paragraph(f"<b>Q: {q}</b>", ParagraphStyle('Q', fontName='Helvetica-Bold', fontSize=8.5, textColor=TEAL, spaceAfter=2))
a_para = Paragraph(f"A: {a}", ParagraphStyle('A', fontName='Helvetica', fontSize=8.5, textColor=DARK, leftIndent=12))
block = Table([[q_para], [a_para]], colWidths=[440])
block.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), TEAL_LIGHT),
('BACKGROUND', (0,1), (-1,1), WHITE),
('BOX', (0,0), (-1,-1), 0.8, TEAL),
('LEFTPADDING', (0,0), (-1,-1), 8),
('RIGHTPADDING', (0,0), (-1,-1), 8),
('TOPPADDING', (0,0), (-1,-1), 4),
('BOTTOMPADDING', (0,0), (-1,-1), 4),
]))
elems.append(block)
elems.append(sp(4))
return elems
# ─── Build PDF ────────────────────────────────────────────────────────────────
OUTPUT = "/home/daytona/workspace/breast-exam-pdf/Breast_Examination_Viva_Guide.pdf"
doc = SimpleDocTemplate(
OUTPUT, pagesize=A4,
leftMargin=20*mm, rightMargin=20*mm,
topMargin=30*mm, bottomMargin=18*mm,
title="Breast Examination - Surgical Viva Guide",
author="Orris Medical AI",
subject="Clinical Surgery - Breast"
)
story = []
story += cover_page()
story += page_history_inspection()
story += page_palpation()
story += page_axilla_dd()
story += page_investigations_staging()
doc.build(story, onFirstPage=add_header_footer, onLaterPages=add_header_footer)
print(f"PDF created: {OUTPUT}")