From e3a5330cc20bd1bd1396951286003abd66037d39 Mon Sep 17 00:00:00 2001 From: Daneel Date: Fri, 20 Feb 2026 17:09:12 +0100 Subject: [PATCH] fix: PDF generator - always one page, canvas API, improved layout --- app/core/pdf_generator.py | 516 ++++++++++++++++++++------------------ 1 file changed, 272 insertions(+), 244 deletions(-) diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py index 9991a63..e581024 100644 --- a/app/core/pdf_generator.py +++ b/app/core/pdf_generator.py @@ -1,289 +1,317 @@ """ -PDF generation for Scenar Creator v3 using ReportLab. -Generates A4 landscape timetable PDF with colored blocks and legend. +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 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 +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 -def hex_to_reportlab_color(hex_color: str) -> colors.Color: - """Convert #RRGGBB hex color to ReportLab color.""" +# 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: - 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 + 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_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 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(time_str: str) -> int: - """Convert HH:MM to minutes since midnight.""" - parts = time_str.split(":") - return int(parts[0]) * 60 + int(parts[1]) +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: - """ - 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." - ) + raise ScenarsError(f"Missing type definition: '{block.type_id}'") - 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 + # Group by date blocks_by_date = defaultdict(list) - for block in doc.blocks: - blocks_by_date[block.date].append(block) - + for b in doc.blocks: + blocks_by_date[b.date].append(b) sorted_dates = sorted(blocks_by_date.keys()) - # Find global time range + # 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] - global_start = (min(all_starts) // 30) * 30 - global_end = ((max(all_ends) + 29) // 30) * 30 + 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 - # 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 + buf = BytesIO() + c = rl_canvas.Canvas(buf, pagesize=landscape(A4)) - # Build table: time column + one column per day - header = [""] + [d for d in sorted_dates] + # ── Layout constants ────────────────────────────────────────────── + x0 = MARGIN + y_top = PAGE_H - MARGIN - table_data = [header] - slot_count = len(time_slots) - 1 + # Header block + title_size = 16 + subtitle_size = 10 + info_size = 8 + header_h = title_size + 4 - # Build grid and track colored cells - cell_colors_list = [] + 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 - 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) + # Legend block (one row per type) + legend_item_h = 12 + legend_h = len(doc.program_types) * legend_item_h + 6 # 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 - )) + footer_h = 10 - doc_pdf.build(elements) - return buffer.getvalue() + # 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()