""" 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 import os import logging from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.units import mm from reportlab.pdfgen import canvas as rl_canvas from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from .validator import ScenarsError logger = logging.getLogger(__name__) # ── Font registration ───────────────────────────────────────────────────────── # LiberationSans supports Czech diacritics. Fallback to Helvetica if not found. _FONT_REGULAR = 'Helvetica' _FONT_BOLD = 'Helvetica-Bold' _FONT_ITALIC = 'Helvetica-Oblique' _LIBERATION_PATHS = [ '/usr/share/fonts/truetype/liberation', '/usr/share/fonts/liberation', '/usr/share/fonts/truetype', ] def _find_font(filename: str): for base in _LIBERATION_PATHS: path = os.path.join(base, filename) if os.path.isfile(path): return path return None def _register_fonts(): global _FONT_REGULAR, _FONT_BOLD, _FONT_ITALIC regular = _find_font('LiberationSans-Regular.ttf') bold = _find_font('LiberationSans-Bold.ttf') italic = _find_font('LiberationSans-Italic.ttf') if regular and bold and italic: try: pdfmetrics.registerFont(TTFont('LiberationSans', regular)) pdfmetrics.registerFont(TTFont('LiberationSans-Bold', bold)) pdfmetrics.registerFont(TTFont('LiberationSans-Italic', italic)) _FONT_REGULAR = 'LiberationSans' _FONT_BOLD = 'LiberationSans-Bold' _FONT_ITALIC = 'LiberationSans-Italic' logger.info('PDF: Using LiberationSans (Czech diacritics supported)') except Exception as e: logger.warning(f'PDF: Font registration failed: {e}') else: logger.warning('PDF: LiberationSans not found, Czech diacritics may be broken') _register_fonts() 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(_FONT_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(_FONT_REGULAR, 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(_FONT_REGULAR, 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(_FONT_REGULAR, 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(_FONT_REGULAR, time_axis_font) set_fill(c, AXIS_TEXT) c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m)) # ── Footnote map: blocks with notes get sequential numbers ──────── footnotes = [] # [(num, block), ...] footnote_map = {} # block.id → footnote number for b in sorted(doc.blocks, key=lambda x: (x.date, x.start)): if b.notes: num = len(footnotes) + 1 footnotes.append((num, b)) footnote_map[b.id] = num # ── 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, _FONT_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) fn_num = footnote_map.get(block.id) title_text = block.title + (' →' if overnight else '') dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78) if is_light(pt.color) else (0.82, 0.82, 0.82)) # Determine vertical layout: how many lines fit? has_responsible = bool(block.responsible) sup_size = max(4.0, block_title_font * 0.65) resp_size = max(4.0, block_time_font) if has_responsible and row_h >= block_title_font + resp_size + 3: # Two-line: title (with superscript) + responsible title_y = row_y + row_h * 0.55 resp_y = row_y + row_h * 0.55 - block_title_font - 1 # responsible c.setFont(_FONT_ITALIC, resp_size) set_fill(c, dim_rgb) c.drawCentredString(bx + bw / 2, resp_y, block.responsible) else: # Single line: title centred title_y = row_y + (row_h - block_title_font) / 2 # Title c.setFont(_FONT_BOLD, block_title_font) set_fill(c, text_rgb) if fn_num is not None: # Draw title then superscript footnote number title_w = c.stringWidth(title_text, _FONT_BOLD, block_title_font) tx = bx + bw / 2 - title_w / 2 c.drawString(tx, title_y, title_text) # Superscript: small number raised by ~font_size * 0.5 c.setFont(_FONT_BOLD, sup_size) set_fill(c, dim_rgb) c.drawString(tx + title_w + 0.5, title_y + block_title_font * 0.45, str(fn_num)) else: c.drawCentredString(bx + bw / 2, title_y, title_text) c.restoreState() # ── Legend ──────────────────────────────────────────────────────── legend_y_top = table_top - num_days * row_h - 6 c.setFont(_FONT_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(_FONT_REGULAR, 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 (page 1) ─────────────────────────────────────────────── gen_date = datetime.now().strftime('%d.%m.%Y %H:%M') c.setFont(_FONT_ITALIC, 6.5) set_fill(c, FOOTER_TEXT) footer_note = ' | Poznámky na str. 2' if footnotes else '' c.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Vygenerováno Scenár Creatorem v4 | {gen_date}{footer_note}') # ── Page 2: Poznámky ke scénáři ─────────────────────────────────── if footnotes: c.showPage() NOTE_MARGIN = 15 * mm ny = PAGE_H - NOTE_MARGIN # Page title c.setFont(_FONT_BOLD, 14) set_fill(c, HEADER_BG) c.drawString(NOTE_MARGIN, ny - 14, 'Poznámky ke scénáři') ny -= 14 + 4 # Subtitle: event title + date ev_info = doc.event.title 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: ev_info += f' | {date_display} – {date_to_display}' else: ev_info += f' | {date_display}' c.setFont(_FONT_REGULAR, 9) set_fill(c, AXIS_TEXT) c.drawString(NOTE_MARGIN, ny - 9, ev_info) ny -= 9 + 8 # Separator line set_stroke(c, GRID_HOUR) c.setLineWidth(0.5) c.line(NOTE_MARGIN, ny, PAGE_W - NOTE_MARGIN, ny) ny -= 8 # Footnote entries for fn_num, block in footnotes: # Block header: number + title + day + time day_label = format_date_cs(block.date) time_str = f'{block.start}–{block.end}' resp_str = f' ({block.responsible})' if block.responsible else '' header_text = f'{fn_num}. {block.title}{resp_str} — {day_label}, {time_str}' # Check space, add new page if needed if ny < NOTE_MARGIN + 30: c.showPage() ny = PAGE_H - NOTE_MARGIN c.setFont(_FONT_BOLD, 9) set_fill(c, HEADER_BG) c.drawString(NOTE_MARGIN, ny - 9, header_text) ny -= 9 + 3 # Note text (wrapped manually) note_text = block.notes or '' words = note_text.split() line_w = PAGE_W - 2 * NOTE_MARGIN - 10 c.setFont(_FONT_REGULAR, 8.5) set_fill(c, (0.15, 0.15, 0.15)) line = '' for word in words: test_line = (line + ' ' + word).strip() if c.stringWidth(test_line, _FONT_REGULAR, 8.5) > line_w: if ny < NOTE_MARGIN + 15: c.showPage() ny = PAGE_H - NOTE_MARGIN c.drawString(NOTE_MARGIN + 8, ny - 8.5, line) ny -= 8.5 + 2 line = word else: line = test_line if line: if ny < NOTE_MARGIN + 15: c.showPage() ny = PAGE_H - NOTE_MARGIN c.drawString(NOTE_MARGIN + 8, ny - 8.5, line) ny -= 8.5 + 2 ny -= 5 # spacing between footnotes # Footer on notes page c.setFont(_FONT_ITALIC, 6.5) set_fill(c, FOOTER_TEXT) c.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Poznámky ke scénáři — {doc.event.title} | {gen_date}') c.save() return buf.getvalue()