#!/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])