""" PDF generation for Scenar Creator v3 using ReportLab. Generates A4 landscape timetable PDF with colored blocks and legend. """ from io import BytesIO from datetime import datetime from collections import defaultdict 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, TA_RIGHT import logging from .validator import ScenarsError logger = logging.getLogger(__name__) def hex_to_reportlab_color(hex_color: str) -> colors.Color: """Convert #RRGGBB hex color to ReportLab color.""" h = hex_color.lstrip('#') if len(h) == 8: h = h[2:] 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 is_light_color(hex_color: str) -> bool: """Check if a color is light (needs dark text).""" h = hex_color.lstrip('#') if len(h) == 8: h = h[2:] if len(h) == 6: r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 return luminance > 0.6 return False def time_to_minutes(time_str: str) -> int: """Convert HH:MM to minutes since midnight.""" parts = time_str.split(":") return int(parts[0]) * 60 + int(parts[1]) def generate_pdf(doc) -> bytes: """ Generate a PDF timetable from a ScenarioDocument. Args: doc: ScenarioDocument instance Returns: bytes: PDF file content """ if not doc.blocks: raise ScenarsError("No blocks provided") type_map = {pt.id: pt for pt in doc.program_types} for block in doc.blocks: if block.type_id not in type_map: raise ScenarsError( f"Missing type definition: '{block.type_id}'. " "Please define all program types." ) buffer = BytesIO() page_w, page_h = landscape(A4) doc_pdf = SimpleDocTemplate( buffer, pagesize=landscape(A4), leftMargin=12 * mm, rightMargin=12 * mm, topMargin=12 * mm, bottomMargin=12 * mm, ) styles = getSampleStyleSheet() title_style = ParagraphStyle( 'TimetableTitle', parent=styles['Title'], fontSize=20, alignment=TA_LEFT, spaceAfter=1 * mm, textColor=colors.Color(0.118, 0.161, 0.231), fontName='Helvetica-Bold' ) subtitle_style = ParagraphStyle( 'TimetableSubtitle', parent=styles['Normal'], fontSize=12, alignment=TA_LEFT, spaceAfter=1 * mm, textColor=colors.Color(0.4, 0.4, 0.4), fontName='Helvetica' ) info_style = ParagraphStyle( 'InfoStyle', parent=styles['Normal'], fontSize=10, alignment=TA_LEFT, spaceAfter=4 * mm, textColor=colors.Color(0.5, 0.5, 0.5), fontName='Helvetica' ) cell_style_white = ParagraphStyle( 'CellStyleWhite', parent=styles['Normal'], fontSize=8, alignment=TA_CENTER, leading=10, textColor=colors.white, fontName='Helvetica-Bold' ) cell_style_dark = ParagraphStyle( 'CellStyleDark', parent=styles['Normal'], fontSize=8, alignment=TA_CENTER, leading=10, textColor=colors.Color(0.1, 0.1, 0.1), fontName='Helvetica-Bold' ) time_style = ParagraphStyle( 'TimeStyle', parent=styles['Normal'], fontSize=7, alignment=TA_RIGHT, leading=9, textColor=colors.Color(0.5, 0.5, 0.5), fontName='Helvetica' ) legend_style = ParagraphStyle( 'LegendStyle', parent=styles['Normal'], fontSize=9, alignment=TA_LEFT, fontName='Helvetica' ) footer_style = ParagraphStyle( 'FooterStyle', parent=styles['Normal'], fontSize=8, alignment=TA_CENTER, textColor=colors.Color(0.6, 0.6, 0.6), fontName='Helvetica-Oblique' ) elements = [] # Header elements.append(Paragraph(doc.event.title, title_style)) if doc.event.subtitle: elements.append(Paragraph(doc.event.subtitle, subtitle_style)) info_parts = [] if doc.event.date: info_parts.append(f"Datum: {doc.event.date}") if doc.event.location: info_parts.append(f"M\u00edsto: {doc.event.location}") if info_parts: elements.append(Paragraph(" | ".join(info_parts), info_style)) elements.append(Spacer(1, 3 * mm)) # Group blocks by date blocks_by_date = defaultdict(list) for block in doc.blocks: blocks_by_date[block.date].append(block) sorted_dates = sorted(blocks_by_date.keys()) # Find global time range all_starts = [time_to_minutes(b.start) for b in doc.blocks] all_ends = [time_to_minutes(b.end) for b in doc.blocks] global_start = (min(all_starts) // 30) * 30 global_end = ((max(all_ends) + 29) // 30) * 30 # Generate 30-min time slots time_slots = [] t = global_start while t <= global_end: h, m = divmod(t, 60) time_slots.append(f"{h:02d}:{m:02d}") t += 30 # Build table: time column + one column per day header = [""] + [d for d in sorted_dates] table_data = [header] slot_count = len(time_slots) - 1 # Build grid and track colored cells cell_colors_list = [] for slot_idx in range(slot_count): slot_start = time_slots[slot_idx] slot_end = time_slots[slot_idx + 1] row = [Paragraph(slot_start, time_style)] for date_key in sorted_dates: cell_content = "" for block in blocks_by_date[date_key]: block_start_min = time_to_minutes(block.start) block_end_min = time_to_minutes(block.end) slot_start_min = time_to_minutes(slot_start) slot_end_min = time_to_minutes(slot_end) if block_start_min <= slot_start_min and block_end_min >= slot_end_min: pt = type_map[block.type_id] light = is_light_color(pt.color) cs = cell_style_dark if light else cell_style_white if block_start_min == slot_start_min: label = block.title if block.responsible: label += f"
{block.responsible}" cell_content = Paragraph(label, cs) else: cell_content = "" cell_colors_list.append((len(table_data), len(row), pt.color)) break row.append(cell_content if cell_content else "") table_data.append(row) # Column widths avail_width = page_w - 24 * mm time_col_width = 18 * mm day_col_width = (avail_width - time_col_width) / max(len(sorted_dates), 1) col_widths = [time_col_width] + [day_col_width] * len(sorted_dates) row_height = 20 table = Table(table_data, colWidths=col_widths, rowHeights=[24] + [row_height] * slot_count) style_cmds = [ ('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.118, 0.161, 0.231)), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 10), ('ALIGN', (0, 0), (-1, 0), 'CENTER'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('ALIGN', (0, 1), (0, -1), 'RIGHT'), ('FONTSIZE', (0, 1), (-1, -1), 7), ('GRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)), ('LINEBELOW', (0, 0), (-1, 0), 1.5, colors.Color(0.118, 0.161, 0.231)), ('ROWBACKGROUNDS', (1, 1), (-1, -1), [colors.white, colors.Color(0.98, 0.98, 0.98)]), ] for r, c, hex_clr in cell_colors_list: rl_color = hex_to_reportlab_color(hex_clr) style_cmds.append(('BACKGROUND', (c, r), (c, r), rl_color)) # Merge cells for blocks spanning multiple time slots for date_idx, date_key in enumerate(sorted_dates): col = date_idx + 1 for block in blocks_by_date[date_key]: block_start_min = time_to_minutes(block.start) block_end_min = time_to_minutes(block.end) start_row = None end_row = None for slot_idx in range(slot_count): slot_min = time_to_minutes(time_slots[slot_idx]) if slot_min == block_start_min: start_row = slot_idx + 1 if slot_idx + 1 < len(time_slots): next_slot_min = time_to_minutes(time_slots[slot_idx + 1]) if next_slot_min == block_end_min: end_row = slot_idx + 1 if start_row is not None and end_row is not None and end_row > start_row: style_cmds.append(('SPAN', (col, start_row), (col, end_row))) table.setStyle(TableStyle(style_cmds)) elements.append(table) # Legend elements.append(Spacer(1, 5 * mm)) legend_items = [] for pt in doc.program_types: legend_items.append([Paragraph(f" {pt.name}", legend_style)]) if legend_items: elements.append(Paragraph("Legenda:", legend_style)) elements.append(Spacer(1, 2 * mm)) legend_table = Table(legend_items, colWidths=[60 * mm]) legend_cmds = [ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('BOX', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)), ('INNERGRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)), ] for i, pt in enumerate(doc.program_types): rl_color = hex_to_reportlab_color(pt.color) legend_cmds.append(('BACKGROUND', (0, i), (0, i), rl_color)) if not is_light_color(pt.color): legend_cmds.append(('TEXTCOLOR', (0, i), (0, i), colors.white)) legend_table.setStyle(TableStyle(legend_cmds)) elements.append(legend_table) # Footer elements.append(Spacer(1, 5 * mm)) gen_date = datetime.now().strftime("%d.%m.%Y %H:%M") elements.append(Paragraph( f"Vygenerov\u00e1no Scen\u00e1r Creatorem | {gen_date}", footer_style )) doc_pdf.build(elements) return buffer.getvalue()