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()