from __future__ import annotations import io from reportlab.lib import colors from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import cm from reportlab.platypus import ( Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) _PAGE_W, _ = landscape(A4) _MARGIN = 1.5 * cm _HEADER_BG = colors.HexColor("#1a56db") _ROW_BG = colors.HexColor("#eef2ff") def _pdf_table(rows: list[dict]) -> Table: if not rows: table_data: list[list] = [["No data available"]] n_cols = 1 else: headers = list(rows[0].keys()) n_cols = len(headers) table_data = [headers] + [ [str(row.get(h, "")) for h in headers] for row in rows ] col_w = (_PAGE_W - 2 * _MARGIN) / n_cols t = Table(table_data, colWidths=[col_w] * n_cols, repeatRows=1) style: list = [ ("BACKGROUND", (0, 0), (-1, 0), _HEADER_BG), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, 0), 8), ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), ("FONTSIZE", (0, 1), (-1, -1), 7), ("ALIGN", (0, 0), (-1, -1), "LEFT"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5), ] for i in range(1, len(table_data)): bg = _ROW_BG if i % 2 == 1 else colors.white style.append(("BACKGROUND", (0, i), (-1, i), bg)) t.setStyle(TableStyle(style)) return t def to_pdf_bytes(rows: list[dict], title: str, subtitle: str = "") -> bytes: """Serialise *rows* to a single-sheet PDF and return the raw bytes.""" buf = io.BytesIO() styles = getSampleStyleSheet() story = [] story.append(Paragraph(title, styles["Title"])) if subtitle: story.append(Spacer(1, 0.2 * cm)) story.append(Paragraph(subtitle, styles["Normal"])) story.append(Spacer(1, 0.5 * cm)) story.append(_pdf_table(rows)) doc = SimpleDocTemplate( buf, pagesize=landscape(A4), leftMargin=_MARGIN, rightMargin=_MARGIN, topMargin=_MARGIN, bottomMargin=_MARGIN, ) doc.build(story) return buf.getvalue()