""" PDF generation for Scenar Creator v4 using ReportLab Canvas API. Layout: rows = days, columns = time slots (15 min). Always exactly one page, A4 landscape. """ 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 # Colors HEADER_BG = (0.118, 0.161, 0.231) # dark navy HEADER_TEXT = (1.0, 1.0, 1.0) AXIS_BG = (0.96, 0.96, 0.97) AXIS_TEXT = (0.45, 0.45, 0.45) GRID_HOUR = (0.78, 0.78, 0.82) GRID_15MIN = (0.90, 0.90, 0.93) ALT_ROW = (0.975, 0.975, 0.98) FOOTER_TEXT = (0.6, 0.6, 0.6) BORDER = (0.82, 0.82, 0.86) def hex_to_rgb(hex_color: str) -> tuple: h = (hex_color or '#888888').lstrip('#') if len(h) == 8: h = h[2:] if len(h) != 6: return (0.7, 0.7, 0.7) return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0) 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 _CS_WEEKDAYS = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle'] def format_date_cs(date_str: str) -> str: """Format date as 'Pondělí (20.2)'.""" from datetime import date as dt_date try: d = dt_date.fromisoformat(date_str) weekday = _CS_WEEKDAYS[d.weekday()] return f"{weekday} ({d.day}.{d.month})" except Exception: return date_str def time_to_min(s: str) -> int: parts = s.split(':') return int(parts[0]) * 60 + int(parts[1]) def fmt_time(total_minutes: int) -> str: norm = total_minutes % 1440 return f"{norm // 60:02d}:{norm % 60:02d}" def set_fill(c, rgb): c.setFillColorRGB(*rgb) def set_stroke(c, rgb): c.setStrokeColorRGB(*rgb) def fill_rect(c, x, y, w, h, fill, stroke=None, sw=0.4): set_fill(c, fill) if stroke: set_stroke(c, stroke) c.setLineWidth(sw) c.rect(x, y, w, h, fill=1, stroke=1) else: c.rect(x, y, w, h, fill=1, stroke=0) def draw_clipped_text(c, text, x, y, w, h, font, size, rgb, align='center'): if not text: return c.saveState() p = c.beginPath() p.rect(x + 1, y, w - 2, h) c.clipPath(p, stroke=0, fill=0) set_fill(c, rgb) c.setFont(font, size) ty = y + (h - size) / 2 if align == 'center': c.drawCentredString(x + w / 2, ty, text) elif align == 'right': c.drawRightString(x + w - 2, ty, text) else: c.drawString(x + 2, ty, 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}'") # Collect dates + time range sorted_dates = doc.get_sorted_dates() num_days = len(sorted_dates) all_starts = [time_to_min(b.start) for b in doc.blocks] all_ends_raw = [] for b in doc.blocks: s = time_to_min(b.start) e = time_to_min(b.end) if e <= s: e += 24 * 60 # overnight: extend past midnight all_ends_raw.append(e) t_start = (min(all_starts) // 60) * 60 t_end = ((max(all_ends_raw) + 14) // 15) * 15 # Guard: clamp to reasonable range t_start = max(0, t_start) t_end = min(t_start + 24 * 60, t_end) total_min = t_end - t_start buf = BytesIO() c = rl_canvas.Canvas(buf, pagesize=landscape(A4)) # ── Layout ──────────────────────────────────────────────────────── x0 = MARGIN y_top = PAGE_H - MARGIN # Header block (title + subtitle + info) TITLE_SIZE = 16 SUB_SIZE = 10 INFO_SIZE = 8 header_h = TITLE_SIZE + 5 if doc.event.subtitle: header_h += SUB_SIZE + 3 has_info = bool(doc.event.date or doc.event.date_from or doc.event.location) if has_info: header_h += INFO_SIZE + 3 header_h += 4 # Legend: one row per type, multi-column LEGEND_ITEM_H = 12 LEGEND_BOX_W = 10 * mm LEGEND_TEXT_W = 48 * mm LEGEND_STRIDE = LEGEND_BOX_W + LEGEND_TEXT_W + 3 * mm available_w_for_legend = PAGE_W - 2 * MARGIN legend_cols = max(1, int(available_w_for_legend / LEGEND_STRIDE)) legend_rows = (len(doc.program_types) + legend_cols - 1) // legend_cols LEGEND_H = legend_rows * LEGEND_ITEM_H + LEGEND_ITEM_H + 4 # +label row FOOTER_H = 10 TIME_AXIS_H = 18 DATE_COL_W = 28 * mm # wider for Czech day names like "Pondělí (20.2)" # Available area for the timetable grid avail_h = PAGE_H - 2 * MARGIN - header_h - LEGEND_H - FOOTER_H - TIME_AXIS_H - 6 row_h = max(10, avail_h / max(num_days, 1)) avail_w = PAGE_W - 2 * MARGIN - DATE_COL_W # 15-min slot width num_15min_slots = total_min // 15 slot_w = avail_w / max(num_15min_slots, 1) # font sizes scale with row/col date_font = max(5.5, min(8.5, row_h * 0.38)) block_title_font = max(5.0, min(8.0, min(row_h, slot_w * 4) * 0.38)) block_time_font = max(4.0, min(6.0, block_title_font - 1.0)) time_axis_font = max(5.5, min(8.0, slot_w * 3)) # ── 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 + 5 if doc.event.subtitle: c.setFont('Helvetica', SUB_SIZE) set_fill(c, (0.4, 0.4, 0.4)) c.drawString(x0, y - SUB_SIZE, doc.event.subtitle) y -= SUB_SIZE + 3 if has_info: parts = [] date_display = doc.event.date_from or doc.event.date date_to_display = doc.event.date_to if date_display: if date_to_display and date_to_display != date_display: parts.append(f'Datum: {date_display} – {date_to_display}') else: parts.append(f'Datum: {date_display}') 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 + 3 y -= 4 # padding # ── Time axis header ────────────────────────────────────────────── table_top = y - TIME_AXIS_H # Date column header (empty corner) fill_rect(c, x0, table_top, DATE_COL_W, TIME_AXIS_H, AXIS_BG, BORDER, 0.4) # Time labels (only whole hours) for m in range(t_start, t_end + 1, 60): slot_idx = (m - t_start) // 15 tx = x0 + DATE_COL_W + slot_idx * slot_w # tick line set_stroke(c, GRID_HOUR) c.setLineWidth(0.5) c.line(tx, table_top, tx, table_top + TIME_AXIS_H) # label label = fmt_time(m) c.setFont('Helvetica', time_axis_font) set_fill(c, AXIS_TEXT) c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, label) # Right border of time axis fill_rect(c, x0, table_top, DATE_COL_W + avail_w, TIME_AXIS_H, AXIS_BG, BORDER, 0.3) # Re-draw to not cover tick lines: draw border rectangle only set_stroke(c, BORDER) c.setLineWidth(0.4) c.rect(x0, table_top, DATE_COL_W + avail_w, TIME_AXIS_H, fill=0, stroke=1) # Re-draw time labels on top of border rect for m in range(t_start, t_end + 1, 60): slot_idx = (m - t_start) // 15 tx = x0 + DATE_COL_W + slot_idx * slot_w c.setFont('Helvetica', time_axis_font) set_fill(c, AXIS_TEXT) c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m)) # ── Draw day rows ───────────────────────────────────────────────── blocks_by_date = defaultdict(list) for b in doc.blocks: blocks_by_date[b.date].append(b) for di, date_key in enumerate(sorted_dates): row_y = table_top - (di + 1) * row_h # Alternating row background row_bg = (1.0, 1.0, 1.0) if di % 2 == 0 else ALT_ROW fill_rect(c, x0 + DATE_COL_W, row_y, avail_w, row_h, row_bg, BORDER, 0.3) # Date label cell fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4) draw_clipped_text(c, format_date_cs(date_key), x0, row_y, DATE_COL_W, row_h, 'Helvetica-Bold', date_font, AXIS_TEXT, 'center') # Vertical grid lines (15-min slots, hour lines darker) for slot_i in range(num_15min_slots + 1): min_at_slot = t_start + slot_i * 15 tx = x0 + DATE_COL_W + slot_i * slot_w is_hour = (min_at_slot % 60 == 0) line_col = GRID_HOUR if is_hour else GRID_15MIN set_stroke(c, line_col) c.setLineWidth(0.5 if is_hour else 0.25) c.line(tx, row_y, tx, row_y + row_h) # Draw program blocks for block in blocks_by_date[date_key]: s = time_to_min(block.start) e = time_to_min(block.end) overnight = e <= s if overnight: e_draw = min(t_end, s + (e + 1440 - s)) # cap at t_end else: e_draw = e cs = max(s, t_start) ce = min(e_draw, t_end) if ce <= cs: continue bx = x0 + DATE_COL_W + (cs - t_start) / 15 * slot_w bw = (ce - cs) / 15 * slot_w pt = type_map[block.type_id] fill_rgb = hex_to_rgb(pt.color) text_rgb = (0.08, 0.08, 0.08) if is_light(pt.color) else (1.0, 1.0, 1.0) inset = 1.0 c.saveState() # Draw block rectangle set_fill(c, fill_rgb) set_stroke(c, (0.0, 0.0, 0.0) if False else fill_rgb) # no border stroke c.roundRect(bx + inset, row_y + inset, bw - 2 * inset, row_h - 2 * inset, 2, fill=1, stroke=0) # Draw text clipped to block p = c.beginPath() p.rect(bx + inset + 1, row_y + inset + 1, bw - 2 * inset - 2, row_h - 2 * inset - 2) c.clipPath(p, stroke=0, fill=0) set_fill(c, text_rgb) title_line = block.title + (' →' if overnight else '') if row_h > block_title_font * 2.2 and block.responsible: # Two lines: title + responsible c.setFont('Helvetica-Bold', block_title_font) c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line) c.setFont('Helvetica', block_time_font) set_fill(c, (text_rgb[0] * 0.8, text_rgb[1] * 0.8, text_rgb[2] * 0.8) if is_light(pt.color) else (0.85, 0.85, 0.85)) c.drawCentredString(bx + bw / 2, row_y + row_h / 2 - block_title_font + 1, block.responsible) else: c.setFont('Helvetica-Bold', block_title_font) c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line) c.restoreState() # ── Legend ──────────────────────────────────────────────────────── legend_y_top = table_top - num_days * row_h - 6 c.setFont('Helvetica-Bold', 7) set_fill(c, HEADER_BG) c.drawString(x0, legend_y_top, 'Legenda:') legend_y_top -= LEGEND_ITEM_H for i, pt in enumerate(doc.program_types): col = i % legend_cols row_idx = i // legend_cols lx = x0 + col * LEGEND_STRIDE ly = legend_y_top - row_idx * LEGEND_ITEM_H fill_rgb = hex_to_rgb(pt.color) # Colored square (NO text inside, just the color) fill_rect(c, lx, ly - LEGEND_ITEM_H + 2, LEGEND_BOX_W, LEGEND_ITEM_H - 2, fill_rgb, BORDER, 0.3) # Type name NEXT TO the square c.setFont('Helvetica', 7) set_fill(c, (0.15, 0.15, 0.15)) c.drawString(lx + LEGEND_BOX_W + 3, ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_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 v4 | {gen_date}') c.save() return buf.getvalue()