Files
scenar-creator/app/core/pdf_generator.py
Daneel e3a5330cc2
Some checks failed
Build & Push Docker / build (push) Has been cancelled
fix: PDF generator - always one page, canvas API, improved layout
2026-02-20 17:09:12 +01:00

318 lines
11 KiB
Python

"""
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.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
# 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:
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(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(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:
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}'")
# 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())
# 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
buf = BytesIO()
c = rl_canvas.Canvas(buf, pagesize=landscape(A4))
# ── Layout constants ──────────────────────────────────────────────
x0 = MARGIN
y_top = PAGE_H - MARGIN
# Header block
title_size = 16
subtitle_size = 10
info_size = 8
header_h = title_size + 4
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
# Legend block (one row per type)
legend_item_h = 12
legend_h = len(doc.program_types) * legend_item_h + 6
# Footer
footer_h = 10
# 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()