fix: PDF generator - always one page, canvas API, improved layout
Some checks failed
Build & Push Docker / build (push) Has been cancelled

This commit is contained in:
2026-02-20 17:09:12 +01:00
parent 7c74af96fb
commit e3a5330cc2

View File

@@ -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"<br/><font size='6'>{block.responsible}</font>"
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("<b>Legenda:</b>", 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()