""" PDF generation for Scenar Creator v3 using ReportLab Canvas API. Generates A4 landscape timetable — always exactly one page. """ from io import BytesIO from datetime import datetime from collections import defaultdict from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.units import mm from reportlab.pdfgen import canvas as rl_canvas import logging from .validator import ScenarsError logger = logging.getLogger(__name__) PAGE_W, PAGE_H = landscape(A4) MARGIN = 10 * mm # Color palette for UI HEADER_BG = (0.118, 0.161, 0.231) # dark navy GRID_LINE = (0.85, 0.85, 0.85) HEADER_TEXT = (1.0, 1.0, 1.0) BODY_ALT = (0.97, 0.97, 0.97) FOOTER_TEXT = (0.6, 0.6, 0.6) LABEL_TEXT = (0.35, 0.35, 0.35) def hex_to_rgb(hex_color: str) -> tuple: h = hex_color.lstrip('#') if len(h) == 8: h = h[2:] if len(h) != 6: return (0.8, 0.8, 0.8) r = int(h[0:2], 16) / 255.0 g = int(h[2:4], 16) / 255.0 b = int(h[4:6], 16) / 255.0 return (r, g, b) def is_light(hex_color: str) -> bool: r, g, b = hex_to_rgb(hex_color) return (0.299 * r + 0.587 * g + 0.114 * b) > 0.6 def time_to_minutes(s: str) -> int: h, m = s.split(":") return int(h) * 60 + int(m) def fmt_time(total_minutes: int) -> str: return f"{total_minutes // 60:02d}:{total_minutes % 60:02d}" def set_fill(c, rgb): c.setFillColorRGB(*rgb) def set_stroke(c, rgb): c.setStrokeColorRGB(*rgb) def draw_rect_filled(c, x, y, w, h, fill_rgb, stroke_rgb=None, stroke_width=0.5): set_fill(c, fill_rgb) if stroke_rgb: set_stroke(c, stroke_rgb) c.setLineWidth(stroke_width) c.rect(x, y, w, h, fill=1, stroke=1) else: c.rect(x, y, w, h, fill=1, stroke=0) def draw_text_clipped(c, text, x, y, w, h, font, size, rgb, align="center"): """Draw text centered/left in a bounding box, clipped to width.""" c.saveState() c.rect(x, y, w, h, fill=0, stroke=0) c.clipPath(c.beginPath(), stroke=0, fill=0) # Use a proper clip path p = c.beginPath() p.rect(x, y, w, h) c.clipPath(p, stroke=0, fill=0) set_fill(c, rgb) c.setFont(font, size) if align == "center": c.drawCentredString(x + w / 2, y + (h - size) / 2 + 1, text) elif align == "right": c.drawRightString(x + w - 2, y + (h - size) / 2 + 1, text) else: c.drawString(x + 2, y + (h - size) / 2 + 1, text) c.restoreState() def generate_pdf(doc) -> bytes: 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}'") # Group by date blocks_by_date = defaultdict(list) for b in doc.blocks: blocks_by_date[b.date].append(b) sorted_dates = sorted(blocks_by_date.keys()) # Time range (round to 30-min boundaries) all_starts = [time_to_minutes(b.start) for b in doc.blocks] all_ends = [time_to_minutes(b.end) for b in doc.blocks] t_start = (min(all_starts) // 30) * 30 t_end = ((max(all_ends) + 29) // 30) * 30 slot_minutes = 30 num_slots = (t_end - t_start) // slot_minutes buf = BytesIO() c = rl_canvas.Canvas(buf, pagesize=landscape(A4)) # ── Layout constants ────────────────────────────────────────────── x0 = MARGIN y_top = PAGE_H - MARGIN # Header block title_size = 16 subtitle_size = 10 info_size = 8 header_h = title_size + 4 has_subtitle = bool(doc.event.subtitle) has_info = bool(doc.event.date or doc.event.location) if has_subtitle: header_h += subtitle_size + 2 if has_info: header_h += info_size + 2 header_h += 4 # bottom padding # Legend block (one row per type) legend_item_h = 12 legend_h = len(doc.program_types) * legend_item_h + 6 # Footer footer_h = 10 # Available height for timetable avail_h = PAGE_H - 2 * MARGIN - header_h - legend_h - footer_h - 6 row_h = max(8, avail_h / max(num_slots, 1)) # Time axis width time_col_w = 14 * mm # Column widths for dates avail_w = PAGE_W - 2 * MARGIN - time_col_w day_col_w = avail_w / max(len(sorted_dates), 1) # Font sizes scale with row height cell_font_size = max(5.5, min(8.0, row_h * 0.45)) time_font_size = max(5.0, min(7.0, row_h * 0.42)) header_col_size = max(6.0, min(9.0, day_col_w * 0.08)) # ── Draw header ─────────────────────────────────────────────────── y = y_top c.setFont("Helvetica-Bold", title_size) set_fill(c, HEADER_BG) c.drawString(x0, y - title_size, doc.event.title) y -= title_size + 4 if has_subtitle: c.setFont("Helvetica", subtitle_size) set_fill(c, (0.4, 0.4, 0.4)) c.drawString(x0, y - subtitle_size, doc.event.subtitle) y -= subtitle_size + 2 if has_info: parts = [] if doc.event.date: parts.append(f"Datum: {doc.event.date}") if doc.event.location: parts.append(f"Místo: {doc.event.location}") c.setFont("Helvetica", info_size) set_fill(c, (0.5, 0.5, 0.5)) c.drawString(x0, y - info_size, " | ".join(parts)) y -= info_size + 2 y -= 6 # padding before table # ── Draw column headers ─────────────────────────────────────────── col_header_h = 16 # Time axis header cell draw_rect_filled(c, x0, y - col_header_h, time_col_w, col_header_h, HEADER_BG) # Date header cells for di, date_key in enumerate(sorted_dates): cx = x0 + time_col_w + di * day_col_w draw_rect_filled(c, cx, y - col_header_h, day_col_w, col_header_h, HEADER_BG, GRID_LINE) c.saveState() p = c.beginPath() p.rect(cx + 1, y - col_header_h + 1, day_col_w - 2, col_header_h - 2) c.clipPath(p, stroke=0, fill=0) set_fill(c, HEADER_TEXT) c.setFont("Helvetica-Bold", header_col_size) c.drawCentredString(cx + day_col_w / 2, y - col_header_h + (col_header_h - header_col_size) / 2, date_key) c.restoreState() y -= col_header_h # ── Draw time grid ──────────────────────────────────────────────── table_top = y table_bottom = y - num_slots * row_h for slot_i in range(num_slots): slot_min = t_start + slot_i * slot_minutes slot_label = fmt_time(slot_min) row_y = y - slot_i * row_h # Alternating row bg for day columns alt_bg = BODY_ALT if slot_i % 2 == 1 else (1.0, 1.0, 1.0) for di in range(len(sorted_dates)): cx = x0 + time_col_w + di * day_col_w draw_rect_filled(c, cx, row_y - row_h, day_col_w, row_h, alt_bg, GRID_LINE, 0.3) # Time axis cell draw_rect_filled(c, x0, row_y - row_h, time_col_w, row_h, (0.96, 0.96, 0.96), GRID_LINE, 0.3) set_fill(c, LABEL_TEXT) c.setFont("Helvetica", time_font_size) c.drawRightString(x0 + time_col_w - 2, row_y - row_h + (row_h - time_font_size) / 2, slot_label) # ── Draw program blocks ─────────────────────────────────────────── for di, date_key in enumerate(sorted_dates): cx = x0 + time_col_w + di * day_col_w for block in blocks_by_date[date_key]: b_start = time_to_minutes(block.start) b_end = time_to_minutes(block.end) if b_start < t_start or b_end > t_end: continue offset_slots_start = (b_start - t_start) / slot_minutes offset_slots_end = (b_end - t_start) / slot_minutes block_top = table_top - offset_slots_start * row_h block_bot = table_top - offset_slots_end * row_h block_h = block_top - block_bot pt = type_map[block.type_id] fill_rgb = hex_to_rgb(pt.color) text_rgb = (0.1, 0.1, 0.1) if is_light(pt.color) else (1.0, 1.0, 1.0) # Block rectangle with slight inset inset = 0.5 bx = cx + inset by = block_bot + inset bw = day_col_w - 2 * inset bh = block_h - 2 * inset c.saveState() draw_rect_filled(c, bx, by, bw, bh, fill_rgb, GRID_LINE, 0.5) # Clip text to block p = c.beginPath() p.rect(bx + 1, by + 1, bw - 2, bh - 2) c.clipPath(p, stroke=0, fill=0) set_fill(c, text_rgb) lines = [block.title] if block.responsible: lines.append(block.responsible) if len(lines) == 1 or bh < cell_font_size * 2.5: c.setFont("Helvetica-Bold", cell_font_size) c.drawCentredString(bx + bw / 2, by + (bh - cell_font_size) / 2, lines[0]) else: c.setFont("Helvetica-Bold", cell_font_size) c.drawCentredString(bx + bw / 2, by + bh / 2 + 1, lines[0]) c.setFont("Helvetica", max(4.5, cell_font_size - 1.0)) set_fill(c, (text_rgb[0] * 0.85, text_rgb[1] * 0.85, text_rgb[2] * 0.85) if is_light(pt.color) else (0.9, 0.9, 0.9)) c.drawCentredString(bx + bw / 2, by + bh / 2 - cell_font_size + 1, lines[1]) c.restoreState() # ── Legend ──────────────────────────────────────────────────────── legend_y = table_bottom - 8 legend_x = x0 legend_label_h = legend_item_h c.setFont("Helvetica-Bold", 7) set_fill(c, HEADER_BG) c.drawString(legend_x, legend_y, "Legenda:") legend_y -= 4 legend_box_w = 10 * mm legend_text_w = 50 * mm legend_col_stride = legend_box_w + legend_text_w + 4 * mm cols = max(1, int((PAGE_W - 2 * MARGIN) / legend_col_stride)) for i, pt in enumerate(doc.program_types): col = i % cols row_idx = i // cols lx = legend_x + col * legend_col_stride ly = legend_y - row_idx * legend_label_h fill_rgb = hex_to_rgb(pt.color) text_rgb = (0.1, 0.1, 0.1) if is_light(pt.color) else (1.0, 1.0, 1.0) draw_rect_filled(c, lx, ly - legend_label_h + 2, legend_box_w, legend_label_h - 2, fill_rgb, GRID_LINE, 0.3) c.setFont("Helvetica-Bold", 6.5) set_fill(c, text_rgb) c.drawCentredString(lx + legend_box_w / 2, ly - legend_label_h + 2 + (legend_label_h - 2 - 6.5) / 2, pt.name[:10]) c.setFont("Helvetica", 7) set_fill(c, LABEL_TEXT) c.drawString(lx + legend_box_w + 2, ly - legend_label_h + 2 + (legend_label_h - 2 - 7) / 2, pt.name) # ── Footer ──────────────────────────────────────────────────────── gen_date = datetime.now().strftime("%d.%m.%Y %H:%M") c.setFont("Helvetica-Oblique", 6.5) set_fill(c, FOOTER_TEXT) c.drawCentredString(PAGE_W / 2, MARGIN - 2, f"Vygenerováno Scenár Creatorem | {gen_date}") c.save() return buf.getvalue()