""" PDF generation for Scenar Creator using ReportLab. Generates A4 landscape timetable PDF with colored blocks and legend. """ import pandas as pd from io import BytesIO from datetime import datetime from reportlab.lib import colors from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.units import mm from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_LEFT import logging from .validator import ScenarsError logger = logging.getLogger(__name__) def hex_to_reportlab_color(hex_color: str) -> colors.Color: """Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color.""" h = hex_color.lstrip('#') if len(h) == 8: # AARRGGBB format h = h[2:] # strip alpha if len(h) == 6: r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) return colors.Color(r / 255.0, g / 255.0, b / 255.0) return colors.white def generate_pdf(data: pd.DataFrame, title: str, detail: str, program_descriptions: dict, program_colors: dict) -> bytes: """ Generate a PDF timetable. Args: data: DataFrame with validated schedule data title: Event title detail: Event detail/description program_descriptions: {type: description} program_colors: {type: color_hex in AARRGGBB format} Returns: bytes: PDF file content Raises: ScenarsError: if data is invalid """ if data.empty: raise ScenarsError("Data is empty after validation") missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors] if missing_types: raise ScenarsError( f"Missing type definitions: {', '.join(missing_types)}. " "Please define all program types." ) buffer = BytesIO() doc = SimpleDocTemplate( buffer, pagesize=landscape(A4), leftMargin=10 * mm, rightMargin=10 * mm, topMargin=10 * mm, bottomMargin=10 * mm, ) styles = getSampleStyleSheet() title_style = ParagraphStyle( 'TimetableTitle', parent=styles['Title'], fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm ) detail_style = ParagraphStyle( 'TimetableDetail', parent=styles['Normal'], fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm, textColor=colors.gray ) cell_style = ParagraphStyle( 'CellStyle', parent=styles['Normal'], fontSize=7, alignment=TA_CENTER, leading=9 ) legend_style = ParagraphStyle( 'LegendStyle', parent=styles['Normal'], fontSize=8, alignment=TA_LEFT ) elements = [] elements.append(Paragraph(title, title_style)) elements.append(Paragraph(detail, detail_style)) data = data.sort_values(by=["Datum", "Zacatek"]) start_times = data["Zacatek"] end_times = data["Konec"] min_time = min(start_times) max_time = max(end_times) time_slots = pd.date_range( datetime.combine(datetime.today(), min_time), datetime.combine(datetime.today(), max_time), freq='15min' ).time # Build header row header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots] table_data = [header] cell_colors = [] # list of (row, col, color) for styling grouped_data = data.groupby(data['Datum']) row_idx = 1 for date_val, group in grouped_data: day_name = date_val.strftime("%A") date_str = date_val.strftime(f"%d.%m {day_name}") row = [date_str] + [""] * len(time_slots) table_data.append(row) row_idx += 1 # Create a sub-row for blocks block_row = [""] * (len(time_slots) + 1) for _, blk in group.iterrows(): try: start_idx = list(time_slots).index(blk["Zacatek"]) + 1 end_idx = list(time_slots).index(blk["Konec"]) + 1 except ValueError: continue label = blk['Program'] if pd.notna(blk.get('Garant')): label += f"\n{blk['Garant']}" block_row[start_idx] = Paragraph(label.replace('\n', '
'), cell_style) rl_color = hex_to_reportlab_color(program_colors[blk["Typ"]]) for ci in range(start_idx, end_idx): cell_colors.append((row_idx, ci, rl_color)) table_data.append(block_row) row_idx += 1 # Calculate column widths avail_width = landscape(A4)[0] - 20 * mm date_col_width = 30 * mm slot_width = max(12 * mm, (avail_width - date_col_width) / max(len(time_slots), 1)) col_widths = [date_col_width] + [slot_width] * len(time_slots) table = Table(table_data, colWidths=col_widths, repeatRows=1) style_cmds = [ ('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.83, 0.83, 0.83)), ('TEXTCOLOR', (0, 0), (-1, 0), colors.black), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('FONTSIZE', (0, 0), (-1, 0), 7), ('FONTSIZE', (0, 1), (-1, -1), 6), ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.Color(0.97, 0.97, 0.97)]), ] for r, c, clr in cell_colors: style_cmds.append(('BACKGROUND', (c, r), (c, r), clr)) table.setStyle(TableStyle(style_cmds)) elements.append(table) # Legend elements.append(Spacer(1, 5 * mm)) elements.append(Paragraph("Legenda:", legend_style)) legend_data = [] legend_colors_list = [] for i, (typ, desc) in enumerate(program_descriptions.items()): legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)]) legend_colors_list.append(hex_to_reportlab_color(program_colors[typ])) if legend_data: legend_table = Table(legend_data, colWidths=[80 * mm]) legend_cmds = [ ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ] for i, clr in enumerate(legend_colors_list): legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr)) legend_table.setStyle(TableStyle(legend_cmds)) elements.append(legend_table) doc.build(elements) return buffer.getvalue()