feat: v4.0 - multi-day horizontal canvas, duration input, overnight blocks, PDF horizontal layout
Some checks failed
Build & Push Docker / build (push) Has been cancelled

This commit is contained in:
2026-02-20 17:31:41 +01:00
parent e3a5330cc2
commit 47add509ca
11 changed files with 1188 additions and 773 deletions

View File

@@ -1,6 +1,7 @@
"""
PDF generation for Scenar Creator v3 using ReportLab Canvas API.
Generates A4 landscape timetable — always exactly one page.
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
@@ -18,25 +19,25 @@ logger = logging.getLogger(__name__)
PAGE_W, PAGE_H = landscape(A4)
MARGIN = 10 * mm
# Color palette for UI
# Colors
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)
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)
LABEL_TEXT = (0.35, 0.35, 0.35)
BORDER = (0.82, 0.82, 0.86)
def hex_to_rgb(hex_color: str) -> tuple:
h = hex_color.lstrip('#')
h = (hex_color or '#888888').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)
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:
@@ -44,13 +45,14 @@ def is_light(hex_color: str) -> bool:
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 time_to_min(s: str) -> int:
parts = s.split(':')
return int(parts[0]) * 60 + int(parts[1])
def fmt_time(total_minutes: int) -> str:
return f"{total_minutes // 60:02d}:{total_minutes % 60:02d}"
norm = total_minutes % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
def set_fill(c, rgb):
@@ -61,33 +63,32 @@ 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)
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_text_clipped(c, text, x, y, w, h, font, size, rgb, align="center"):
"""Draw text centered/left in a bounding box, clipped to width."""
def draw_clipped_text(c, text, x, y, w, h, font, size, rgb, align='center'):
if not text:
return
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)
p.rect(x + 1, y, w - 2, 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)
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, y + (h - size) / 2 + 1, text)
c.drawString(x + 2, ty, text)
c.restoreState()
@@ -100,218 +101,251 @@ def generate_pdf(doc) -> bytes:
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())
# Collect dates + time range
sorted_dates = doc.get_sorted_dates()
num_days = len(sorted_dates)
# 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
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 constants ──────────────────────────────────────────────
# ── Layout ────────────────────────────────────────────────────────
x0 = MARGIN
y_top = PAGE_H - MARGIN
# Header block
title_size = 16
subtitle_size = 10
info_size = 8
header_h = title_size + 4
# Header block (title + subtitle + info)
TITLE_SIZE = 16
SUB_SIZE = 10
INFO_SIZE = 8
has_subtitle = bool(doc.event.subtitle)
has_info = bool(doc.event.date or doc.event.location)
if has_subtitle:
header_h += subtitle_size + 2
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 + 2
header_h += 4 # bottom padding
header_h += INFO_SIZE + 3
header_h += 4
# Legend block (one row per type)
legend_item_h = 12
legend_h = len(doc.program_types) * legend_item_h + 6
# 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
footer_h = 10
FOOTER_H = 10
TIME_AXIS_H = 18
DATE_COL_W = 18 * mm
# 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))
# 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))
# 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)
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 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))
# 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)
c.setFont('Helvetica-Bold', TITLE_SIZE)
set_fill(c, HEADER_BG)
c.drawString(x0, y - title_size, doc.event.title)
y -= title_size + 4
c.drawString(x0, y - TITLE_SIZE, doc.event.title)
y -= TITLE_SIZE + 5
if has_subtitle:
c.setFont("Helvetica", subtitle_size)
if doc.event.subtitle:
c.setFont('Helvetica', SUB_SIZE)
set_fill(c, (0.4, 0.4, 0.4))
c.drawString(x0, y - subtitle_size, doc.event.subtitle)
y -= subtitle_size + 2
c.drawString(x0, y - SUB_SIZE, doc.event.subtitle)
y -= SUB_SIZE + 3
if has_info:
parts = []
if doc.event.date:
parts.append(f"Datum: {doc.event.date}")
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)
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
c.drawString(x0, y - INFO_SIZE, ' | '.join(parts))
y -= INFO_SIZE + 3
y -= 4 # padding
y -= 6 # padding before table
# ── 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)
# ── 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()
row_y = table_top - (di + 1) * row_h
y -= col_header_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)
# ── Draw time grid ────────────────────────────────────────────────
table_top = y
table_bottom = y - num_slots * row_h
# Date label cell
fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4)
draw_clipped_text(c, date_key, x0, row_y, DATE_COL_W, row_h,
'Helvetica-Bold', date_font, AXIS_TEXT, 'center')
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
# 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)
# 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
# Draw program blocks
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:
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
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
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.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
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_rect_filled(c, bx, by, bw, bh, fill_rgb, GRID_LINE, 0.5)
# 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)
# Clip text to block
# Draw text clipped to block
p = c.beginPath()
p.rect(bx + 1, by + 1, bw - 2, bh - 2)
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)
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])
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", 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.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 = table_bottom - 8
legend_x = x0
legend_label_h = legend_item_h
legend_y_top = table_top - num_days * row_h - 6
c.setFont("Helvetica-Bold", 7)
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))
c.drawString(x0, legend_y_top, 'Legenda:')
legend_y_top -= LEGEND_ITEM_H
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
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)
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])
# 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)
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)
# 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)
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.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Vygenerováno Scenár Creatorem v4 | {gen_date}')
c.save()
return buf.getvalue()