#!/usr/bin/env python3
"""
Relevant Reflex — Panel Book PDF Generator
Reads JSON data, produces a branded PDF with charts.
Usage: python3 generate_panelbook.py input.json output.pdf
"""
import sys, json, io, math
from datetime import datetime
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm, cm
from reportlab.lib.colors import HexColor, white, black
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Image, PageBreak,
Table, TableStyle, KeepTogether
)
from reportlab.lib import colors
from reportlab.pdfgen import canvas
# ─── Brand Palette ───
RR_GREEN = HexColor('#059669')
RR_DARK = HexColor('#064e3b')
RR_LIGHT = HexColor('#ecfdf5')
SLATE_900 = HexColor('#0f172a')
SLATE_700 = HexColor('#334155')
SLATE_500 = HexColor('#64748b')
SLATE_300 = HexColor('#cbd5e1')
SLATE_100 = HexColor('#f1f5f9')
SLATE_50 = HexColor('#f8fafc')
WHITE = white
# Chart color palette (muted, professional)
CHART_COLORS = [
'#059669', '#0d9488', '#0891b2', '#0284c7', '#4f46e5',
'#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#d97706',
'#65a30d', '#16a34a', '#94a3b8', '#64748b', '#475569', '#334155'
]
W, H = A4 # 595.27, 841.89
# ──────────────────────────────────────────────
# Chart Generators (return PNG bytes via BytesIO)
# ──────────────────────────────────────────────
def _apply_style():
plt.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Helvetica', 'Arial', 'DejaVu Sans'],
'font.size': 8,
'axes.labelsize': 8,
'axes.titlesize': 9,
'xtick.labelsize': 7,
'ytick.labelsize': 7,
'figure.facecolor': 'white',
'axes.facecolor': 'white',
'axes.edgecolor': '#cbd5e1',
'axes.grid': False,
'axes.spines.top': False,
'axes.spines.right': False,
})
def make_pie_chart(labels, values, title='', w_inch=3.8, h_inch=2.8):
_apply_style()
fig, ax = plt.subplots(figsize=(w_inch, h_inch))
clrs = CHART_COLORS[:len(labels)]
wedges, texts, autotexts = ax.pie(
values, labels=None, autopct='%1.1f%%', startangle=140,
colors=clrs, pctdistance=0.75,
wedgeprops=dict(width=0.5, edgecolor='white', linewidth=2)
)
for t in autotexts:
t.set_fontsize(7)
t.set_color('#334155')
t.set_fontweight('bold')
ax.legend(
[f'{l} ({v:,})' for l, v in zip(labels, values)],
loc='center left', bbox_to_anchor=(1, 0.5),
fontsize=7, frameon=False, labelspacing=0.8
)
if title:
ax.set_title(title, fontsize=9, fontweight='bold', color='#0f172a', pad=8)
fig.tight_layout(pad=0.5)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=180, bbox_inches='tight', facecolor='white')
plt.close(fig)
buf.seek(0)
return buf
def make_hbar_chart(labels, values, title='', w_inch=5.2, h_inch=None, color='#059669'):
_apply_style()
n = len(labels)
if h_inch is None:
h_inch = max(1.6, min(n * 0.32 + 0.6, 7.5))
fig, ax = plt.subplots(figsize=(w_inch, h_inch))
# Truncate long labels
short_labels = [l[:35] + '...' if len(str(l)) > 35 else str(l) for l in labels]
y_pos = range(n)
bars = ax.barh(y_pos, values, color=color, height=0.65, edgecolor='white', linewidth=0.5)
ax.set_yticks(y_pos)
ax.set_yticklabels(short_labels, fontsize=7, color='#334155')
ax.invert_yaxis()
ax.set_xlabel('')
# Value labels on bars
max_val = max(values) if values else 1
for bar, val in zip(bars, values):
pct_of_total = (val / sum(values) * 100) if sum(values) > 0 else 0
ax.text(bar.get_width() + max_val * 0.02, bar.get_y() + bar.get_height()/2,
f'{val:,} ({pct_of_total:.1f}%)', va='center', fontsize=6.5, color='#64748b')
ax.set_xlim(0, max_val * 1.35)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f'{int(x):,}'))
if title:
ax.set_title(title, fontsize=9, fontweight='bold', color='#0f172a', pad=8, loc='left')
ax.spines['left'].set_color('#e2e8f0')
ax.spines['bottom'].set_color('#e2e8f0')
ax.tick_params(axis='x', colors='#94a3b8')
fig.tight_layout(pad=0.5)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=180, bbox_inches='tight', facecolor='white')
plt.close(fig)
buf.seek(0)
return buf
def make_vbar_chart(labels, values, title='', w_inch=5.2, h_inch=2.5, color='#059669'):
_apply_style()
fig, ax = plt.subplots(figsize=(w_inch, h_inch))
x_pos = range(len(labels))
bars = ax.bar(x_pos, values, color=color, width=0.6, edgecolor='white', linewidth=0.5)
ax.set_xticks(x_pos)
ax.set_xticklabels(labels, fontsize=7, color='#334155', rotation=0)
ax.set_ylabel('')
for bar, val in zip(bars, values):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(values)*0.02,
f'{val:,}', ha='center', va='bottom', fontsize=7, color='#334155', fontweight='bold')
ax.set_ylim(0, max(values) * 1.2 if values else 1)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f'{int(x):,}'))
if title:
ax.set_title(title, fontsize=9, fontweight='bold', color='#0f172a', pad=8, loc='left')
ax.spines['left'].set_color('#e2e8f0')
ax.spines['bottom'].set_color('#e2e8f0')
ax.tick_params(axis='y', colors='#94a3b8')
fig.tight_layout(pad=0.5)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=180, bbox_inches='tight', facecolor='white')
plt.close(fig)
buf.seek(0)
return buf
# ──────────────────────────────────────────────
# Custom Page Template (header/footer)
# ──────────────────────────────────────────────
class PanelBookTemplate(SimpleDocTemplate):
def __init__(self, *args, **kwargs):
self.page_count = 0
self.is_cover = True
super().__init__(*args, **kwargs)
def afterPage(self):
self.page_count += 1
def draw_page(canvas_obj, doc):
"""Draw header line and footer on every page except cover."""
canvas_obj.saveState()
if doc.page_count == 0:
# Cover page — no header/footer
canvas_obj.restoreState()
return
page_num = doc.page_count + 1
# Top line
canvas_obj.setStrokeColor(RR_GREEN)
canvas_obj.setLineWidth(1.5)
canvas_obj.line(30, H - 28, W - 30, H - 28)
# Header text
canvas_obj.setFont('Helvetica-Bold', 7)
canvas_obj.setFillColor(SLATE_500)
canvas_obj.drawString(32, H - 24, 'RELEVANT REFLEX')
canvas_obj.setFont('Helvetica', 7)
canvas_obj.drawRightString(W - 32, H - 24, 'Panel Book')
# Footer
canvas_obj.setStrokeColor(SLATE_300)
canvas_obj.setLineWidth(0.5)
canvas_obj.line(30, 32, W - 30, 32)
canvas_obj.setFont('Helvetica', 6.5)
canvas_obj.setFillColor(SLATE_500)
canvas_obj.drawString(32, 20, 'Confidential — Relevant Reflex Panel Book')
canvas_obj.drawRightString(W - 32, 20, f'Page {page_num}')
canvas_obj.restoreState()
# ──────────────────────────────────────────────
# Styles
# ──────────────────────────────────────────────
def get_styles():
return {
'section_title': ParagraphStyle(
'SectionTitle', fontName='Helvetica-Bold', fontSize=16,
textColor=SLATE_900, spaceAfter=4, leading=20
),
'section_sub': ParagraphStyle(
'SectionSub', fontName='Helvetica', fontSize=8.5,
textColor=SLATE_500, spaceAfter=16, leading=12
),
'heading2': ParagraphStyle(
'Heading2', fontName='Helvetica-Bold', fontSize=11,
textColor=RR_DARK, spaceAfter=6, spaceBefore=14, leading=14
),
'body': ParagraphStyle(
'Body', fontName='Helvetica', fontSize=8.5,
textColor=SLATE_700, spaceAfter=6, leading=12
),
'body_center': ParagraphStyle(
'BodyCenter', fontName='Helvetica', fontSize=8.5,
textColor=SLATE_700, spaceAfter=6, leading=12, alignment=TA_CENTER
),
'small': ParagraphStyle(
'Small', fontName='Helvetica', fontSize=7,
textColor=SLATE_500, spaceAfter=4, leading=10
),
'small_center': ParagraphStyle(
'SmallCenter', fontName='Helvetica', fontSize=7,
textColor=SLATE_500, spaceAfter=4, leading=10, alignment=TA_CENTER
),
'metric_value': ParagraphStyle(
'MetricValue', fontName='Helvetica-Bold', fontSize=22,
textColor=RR_GREEN, alignment=TA_CENTER, spaceAfter=0, leading=26
),
'metric_label': ParagraphStyle(
'MetricLabel', fontName='Helvetica', fontSize=7.5,
textColor=SLATE_500, alignment=TA_CENTER, spaceAfter=0, leading=10
),
'question_title': ParagraphStyle(
'QuestionTitle', fontName='Helvetica-Bold', fontSize=9,
textColor=SLATE_900, spaceAfter=2, spaceBefore=6, leading=12
),
'question_sub': ParagraphStyle(
'QuestionSub', fontName='Helvetica', fontSize=7,
textColor=SLATE_500, spaceAfter=6, leading=10
),
}
def format_question(qid):
"""Convert question_id like 'education_level' to 'Education Level'."""
return qid.replace('_', ' ').title()
# ──────────────────────────────────────────────
# Build PDF
# ──────────────────────────────────────────────
def build_pdf(data, output_path):
doc = PanelBookTemplate(
output_path, pagesize=A4,
topMargin=38, bottomMargin=44,
leftMargin=32, rightMargin=32,
title='Relevant Reflex Panel Book',
author='Relevant Reflex'
)
styles = get_styles()
story = []
content_width = W - 64 # left + right margins
# ════════════════════════════════════════════
# PAGE 1: COVER
# ════════════════════════════════════════════
story.append(Spacer(1, 120))
# Logo block
logo_data = [
[Paragraph('RR',
ParagraphStyle('Logo', alignment=TA_CENTER))]
]
logo_table = Table(logo_data, colWidths=[80], rowHeights=[64])
logo_table.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,-1), RR_GREEN),
('ALIGN', (0,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('ROUNDEDCORNERS', [12, 12, 12, 12]),
]))
# Wrap logo in centered table
logo_wrapper = Table([[logo_table]], colWidths=[content_width])
logo_wrapper.setStyle(TableStyle([('ALIGN', (0,0), (-1,-1), 'CENTER')]))
story.append(logo_wrapper)
story.append(Spacer(1, 24))
# Title
story.append(Paragraph('Relevant Reflex', ParagraphStyle(
'CoverBrand', fontName='Helvetica-Bold', fontSize=32,
textColor=RR_DARK, alignment=TA_CENTER, leading=38
)))
story.append(Spacer(1, 4))
story.append(Paragraph('Panel Book', ParagraphStyle(
'CoverTitle', fontName='Helvetica', fontSize=24,
textColor=SLATE_500, alignment=TA_CENTER, leading=30
)))
story.append(Spacer(1, 10))
# Green divider
divider_data = [[' ']]
divider = Table(divider_data, colWidths=[80], rowHeights=[3])
divider.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,-1), RR_GREEN),
('ROUNDEDCORNERS', [2, 2, 2, 2]),
]))
div_wrapper = Table([[divider]], colWidths=[content_width])
div_wrapper.setStyle(TableStyle([('ALIGN', (0,0), (-1,-1), 'CENTER')]))
story.append(div_wrapper)
story.append(Spacer(1, 20))
# Generation info
story.append(Paragraph(
f'Generated on {data.get("generated_at", "")}',
ParagraphStyle('CoverDate', fontName='Helvetica', fontSize=10,
textColor=SLATE_700, alignment=TA_CENTER, leading=14)
))
story.append(Spacer(1, 60))
# Disclaimer box
disclaimer_text = (
'All the data in this panel book are 100% based on the actual counts of the panel '
'and not added/edited by human. This is a real-time snapshot generated at the '
'date and time mentioned above.'
)
disc_data = [[Paragraph(disclaimer_text, ParagraphStyle(
'Disclaimer', fontName='Helvetica', fontSize=7.5,
textColor=SLATE_700, alignment=TA_CENTER, leading=11
))]]
disc_table = Table(disc_data, colWidths=[content_width * 0.75])
disc_table.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,-1), SLATE_50),
('BOX', (0,0), (-1,-1), 0.5, SLATE_300),
('TOPPADDING', (0,0), (-1,-1), 12),
('BOTTOMPADDING', (0,0), (-1,-1), 12),
('LEFTPADDING', (0,0), (-1,-1), 16),
('RIGHTPADDING', (0,0), (-1,-1), 16),
]))
disc_wrapper = Table([[disc_table]], colWidths=[content_width])
disc_wrapper.setStyle(TableStyle([('ALIGN', (0,0), (-1,-1), 'CENTER')]))
story.append(disc_wrapper)
story.append(Spacer(1, 80))
# Footer note on cover
story.append(Paragraph(
'www.relevantreflex.com',
ParagraphStyle('CoverURL', fontName='Helvetica', fontSize=8,
textColor=SLATE_500, alignment=TA_CENTER)
))
story.append(PageBreak())
# ════════════════════════════════════════════
# PAGE 2: PANEL QUALITY OVERVIEW
# ════════════════════════════════════════════
story.append(Paragraph('Panel Overview', styles['section_title']))
story.append(Paragraph('Key quality metrics and panel health indicators.', styles['section_sub']))
# Metrics grid (2 rows x 3 cols)
verified = data.get('verified_members', 0)
active = data.get('active_members', 0)
total = data.get('total_members', 0)
quality = data.get('quality', {})
def metric_cell(value, label):
return [
Paragraph(str(value), styles['metric_value']),
Paragraph(label, styles['metric_label'])
]
metrics_data = [
[
metric_cell(f'{total:,}', 'Total Registered'),
metric_cell(f'{active:,}', 'Active Members'),
metric_cell(f'{verified:,}', 'Email Verified'),
],
[
metric_cell(f'{quality.get("members_with_profiler", 0):,}', 'Profiler Completed'),
metric_cell(f'{quality.get("mobile_verified", 0):,}', 'Mobile Verified'),
metric_cell(f'{quality.get("avg_profiler_completion", 0):.0f}%', 'Avg. Profiler Completion'),
]
]
# Flatten for Table (each metric_cell is a list of 2 paragraphs, put in inner table)
def metric_inner(val_label_pair):
t = Table([val_label_pair], colWidths=[content_width/3 - 12])
t.setStyle(TableStyle([
('ALIGN', (0,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('TOPPADDING', (0,0), (-1,-1), 14),
('BOTTOMPADDING', (0,0), (-1,-1), 14),
]))
return t
row1 = [metric_inner(m) for m in metrics_data[0]]
row2 = [metric_inner(m) for m in metrics_data[1]]
col_w = content_width / 3
metrics_table = Table([row1, row2], colWidths=[col_w]*3, rowHeights=[72, 72])
metrics_table.setStyle(TableStyle([
('GRID', (0,0), (-1,-1), 0.5, SLATE_300),
('BACKGROUND', (0,0), (-1,-1), SLATE_50),
('ALIGN', (0,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('ROUNDEDCORNERS', [8, 8, 8, 8]),
]))
story.append(metrics_table)
story.append(Spacer(1, 20))
# Project Stats
if quality.get('total_projects', 0) > 0:
story.append(Paragraph('Research Activity', styles['heading2']))
proj_data = [
['Total Projects', 'Invitations Sent', 'Completed Surveys'],
[
Paragraph(f'{quality.get("total_projects", 0):,}', styles['body_center']),
Paragraph(f'{quality.get("total_surveys_sent", 0):,}', styles['body_center']),
Paragraph(f'{quality.get("total_completes", 0):,}', styles['body_center']),
]
]
proj_table = Table(proj_data, colWidths=[col_w]*3)
proj_table.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), RR_DARK),
('TEXTCOLOR', (0,0), (-1,0), WHITE),
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,0), 7.5),
('ALIGN', (0,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('GRID', (0,0), (-1,-1), 0.5, SLATE_300),
('TOPPADDING', (0,0), (-1,-1), 8),
('BOTTOMPADDING', (0,0), (-1,-1), 8),
]))
story.append(proj_table)
# Profiler completion rates
story.append(Spacer(1, 18))
story.append(Paragraph('Profiler Completion Rates', styles['heading2']))
prof_comp = data.get('profiler_completion', {})
prof_sections = data.get('profiler_sections', {})
comp_header = ['Section', 'Started', 'Completed']
comp_rows = [comp_header]
for key, label in prof_sections.items():
pc = prof_comp.get(key, {})
comp_rows.append([
label,
f'{pc.get("started", 0):,}',
f'{pc.get("completed", 0):,}'
])
comp_table = Table(comp_rows, colWidths=[content_width * 0.55, content_width * 0.225, content_width * 0.225])
comp_table.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), RR_DARK),
('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'),
('ALIGN', (1,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('GRID', (0,0), (-1,-1), 0.5, SLATE_300),
('ROWBACKGROUNDS', (0,1), (-1,-1), [WHITE, SLATE_50]),
('TOPPADDING', (0,0), (-1,-1), 6),
('BOTTOMPADDING', (0,0), (-1,-1), 6),
('LEFTPADDING', (0,0), (0,-1), 10),
]))
story.append(comp_table)
story.append(PageBreak())
# ════════════════════════════════════════════
# PAGE 3: DEMOGRAPHICS
# ════════════════════════════════════════════
story.append(Paragraph('Demographics', styles['section_title']))
story.append(Paragraph(
f'Distribution of {verified:,} active, verified panel members.',
styles['section_sub']
))
# Gender Pie
gender_data = data.get('gender', [])
if gender_data:
labels = [g['gender'] or 'Not Specified' for g in gender_data]
values = [g['count'] for g in gender_data]
buf = make_pie_chart(labels, values, 'Gender Distribution')
img = Image(buf, width=content_width * 0.75, height=content_width * 0.55)
story.append(img)
story.append(Spacer(1, 12))
# Age Bar
age_data = data.get('age', [])
if age_data:
labels = [a['age_group'] for a in age_data]
values = [a['count'] for a in age_data]
buf = make_vbar_chart(labels, values, 'Age Distribution', w_inch=5.2, h_inch=2.5, color='#059669')
img = Image(buf, width=content_width, height=content_width * 0.48)
story.append(img)
story.append(Spacer(1, 12))
# Geography Bar
geo_data = data.get('geography', [])
if geo_data:
story.append(PageBreak())
story.append(Paragraph('Geographic Distribution', styles['section_title']))
story.append(Paragraph('Top 20 postcode regions by panel size.', styles['section_sub']))
labels = [g['region'] for g in geo_data]
values = [g['count'] for g in geo_data]
buf = make_hbar_chart(labels, values, 'Members by Postcode Prefix (Top 20)', color='#0d9488')
h_ratio = max(1.6, min(len(labels) * 0.32 + 0.6, 7.5)) / 5.2
img = Image(buf, width=content_width, height=content_width * h_ratio)
story.append(img)
story.append(PageBreak())
# ════════════════════════════════════════════
# PAGES 4+: PROFILER SECTIONS
# ════════════════════════════════════════════
profiler_data = data.get('profiler_data', {})
section_colors = list(CHART_COLORS)
for sec_idx, (sec_key, sec_label) in enumerate(prof_sections.items()):
if sec_key not in profiler_data:
continue
questions = profiler_data[sec_key]
if not questions:
continue
sec_color = section_colors[sec_idx % len(section_colors)]
# Section header
story.append(Paragraph(sec_label, styles['section_title']))
pc = prof_comp.get(sec_key, {})
story.append(Paragraph(
f'{len(questions)} question{"s" if len(questions) != 1 else ""} — '
f'{pc.get("completed", 0):,} members completed this section.',
styles['section_sub']
))
for q_idx, (qid, qdata) in enumerate(questions.items()):
dist = qdata.get('distribution', [])
resp_count = qdata.get('respondent_count', 0)
if not dist:
continue
labels = [d['label'] for d in dist]
values = [d['count'] for d in dist]
q_title = format_question(qid)
# Decide chart type
n_cats = len(dist)
elements = []
elements.append(Paragraph(q_title, styles['question_title']))
elements.append(Paragraph(
f'{resp_count:,} respondents',
styles['question_sub']
))
if n_cats <= 5 and n_cats >= 2:
# Pie chart for small category counts
buf = make_pie_chart(labels, values, '', w_inch=3.8, h_inch=2.2)
img = Image(buf, width=content_width * 0.72, height=content_width * 0.42)
else:
# Horizontal bar for larger category counts
buf = make_hbar_chart(labels, values, '', color=sec_color)
n = len(labels)
h_ratio = max(1.4, min(n * 0.3 + 0.5, 6.5)) / 5.2
img = Image(buf, width=content_width * 0.92, height=content_width * h_ratio * 0.92)
elements.append(img)
elements.append(Spacer(1, 10))
# Try to keep question + chart together
story.append(KeepTogether(elements))
story.append(PageBreak())
# ════════════════════════════════════════════
# LAST PAGE: CONTACT
# ════════════════════════════════════════════
story.append(Spacer(1, 140))
# Logo
contact_logo = [[Paragraph('RR',
ParagraphStyle('CL', alignment=TA_CENTER))]]
cl_table = Table(contact_logo, colWidths=[60], rowHeights=[48])
cl_table.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,-1), RR_GREEN),
('ALIGN', (0,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('ROUNDEDCORNERS', [10, 10, 10, 10]),
]))
cl_wrapper = Table([[cl_table]], colWidths=[content_width])
cl_wrapper.setStyle(TableStyle([('ALIGN', (0,0), (-1,-1), 'CENTER')]))
story.append(cl_wrapper)
story.append(Spacer(1, 16))
story.append(Paragraph('Relevant Reflex', ParagraphStyle(
'ContactBrand', fontName='Helvetica-Bold', fontSize=20,
textColor=RR_DARK, alignment=TA_CENTER, leading=24
)))
story.append(Spacer(1, 4))
story.append(Paragraph("India's Premier Online Research Panel", ParagraphStyle(
'ContactTag', fontName='Helvetica', fontSize=9,
textColor=SLATE_500, alignment=TA_CENTER, leading=13
)))
story.append(Spacer(1, 30))
# Contact details
contact_items = [
('Email', 'contact@relevantreflex.com'),
('Support', 'support@relevantreflex.com'),
('Web', 'www.relevantreflex.com'),
('Client Portal', 'www.relevantreflex.com/clients'),
('Location', 'Tamilnadu, India'),
]
for label, value in contact_items:
story.append(Paragraph(
f'{label} '
f'{value}',
ParagraphStyle('ContactItem', fontName='Helvetica', fontSize=8.5,
alignment=TA_CENTER, leading=18, textColor=SLATE_700)
))
story.append(Spacer(1, 40))
# Divider
div2 = Table([[' ']], colWidths=[60], rowHeights=[2])
div2.setStyle(TableStyle([('BACKGROUND', (0,0), (-1,-1), SLATE_300)]))
div2_w = Table([[div2]], colWidths=[content_width])
div2_w.setStyle(TableStyle([('ALIGN', (0,0), (-1,-1), 'CENTER')]))
story.append(div2_w)
story.append(Spacer(1, 14))
story.append(Paragraph(
'For panel inquiries, project feasibility, or partnership opportunities, '
'please reach out to our client services team.',
styles['small_center']
))
story.append(Spacer(1, 6))
story.append(Paragraph(
f'This document was generated on {data.get("generated_at", "")}.',
ParagraphStyle('FootNote', fontName='Helvetica', fontSize=6.5,
textColor=SLATE_500, alignment=TA_CENTER, leading=9)
))
# ════════════════════════════════════════════
# BUILD
# ════════════════════════════════════════════
doc.build(story, onFirstPage=draw_page, onLaterPages=draw_page)
print(f'PDF generated: {output_path}')
# ─── Main ───
if __name__ == '__main__':
if len(sys.argv) != 3:
print('Usage: python3 generate_panelbook.py input.json output.pdf')
sys.exit(1)
with open(sys.argv[1], 'r') as f:
data = json.load(f)
build_pdf(data, sys.argv[2])