Files
scenar-creator/app/core/pdf_generator.py
Martin Sukany 0a694ce63a
Some checks failed
Build & Push Docker / build (push) Has been cancelled
feat: přidáno URL pole pro bloky – klikatelný odkaz v PDF
- Block model: nové volitelné pole 'url' (Optional[str])
- Frontend: URL input v modálu pro přidání/editaci bloku
- PDF generátor: c.linkURL() – celý blok je klikatelný odkaz
- sample.json: ukázkový blok s URL
- index.html: dokumentace URL pole
- .github/copilot-instructions.md: přidány Copilot instrukce
2026-03-14 19:10:13 +01:00

739 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
from datetime import datetime
from collections import defaultdict
import os
import logging
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from .validator import ScenarsError
logger = logging.getLogger(__name__)
# ── Font registration ─────────────────────────────────────────────────────────
# Czech diacritics require a TrueType font. Try LiberationSans (Linux/Docker),
# then Arial (macOS), then fall back to Helvetica (no diacritics).
_FONT_REGULAR = 'Helvetica'
_FONT_BOLD = 'Helvetica-Bold'
_FONT_ITALIC = 'Helvetica-Oblique'
# Font candidates: (family_name, [(search_dirs, regular, bold, italic)])
_FONT_CANDIDATES = [
# LiberationSans — Linux / Docker (fonts-liberation package)
('LiberationSans', [
'/usr/share/fonts/truetype/liberation',
'/usr/share/fonts/liberation',
'/usr/share/fonts/truetype',
], 'LiberationSans-Regular.ttf', 'LiberationSans-Bold.ttf', 'LiberationSans-Italic.ttf'),
# Arial — macOS system font
('Arial', [
'/System/Library/Fonts/Supplemental',
'/Library/Fonts',
], 'Arial.ttf', 'Arial Bold.ttf', 'Arial Italic.ttf'),
]
def _find_font_file(dirs: list, filename: str):
for base in dirs:
path = os.path.join(base, filename)
if os.path.isfile(path):
return path
return None
def _register_fonts():
global _FONT_REGULAR, _FONT_BOLD, _FONT_ITALIC
for family, dirs, reg_name, bold_name, italic_name in _FONT_CANDIDATES:
regular = _find_font_file(dirs, reg_name)
bold = _find_font_file(dirs, bold_name)
italic = _find_font_file(dirs, italic_name)
if regular and bold and italic:
try:
pdfmetrics.registerFont(TTFont(f'{family}', regular))
pdfmetrics.registerFont(TTFont(f'{family}-Bold', bold))
pdfmetrics.registerFont(TTFont(f'{family}-Italic', italic))
_FONT_REGULAR = family
_FONT_BOLD = f'{family}-Bold'
_FONT_ITALIC = f'{family}-Italic'
logger.info(f'PDF: Using {family} (Czech diacritics supported)')
return
except Exception as e:
logger.warning(f'PDF: {family} registration failed: {e}')
logger.warning('PDF: No TrueType font found, Czech diacritics may be broken')
_register_fonts()
PAGE_W, PAGE_H = landscape(A4)
MARGIN = 10 * mm
# Colors
HEADER_BG = (0.118, 0.161, 0.231) # dark navy
HEADER_TEXT = (1.0, 1.0, 1.0)
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)
BORDER = (0.82, 0.82, 0.86)
def hex_to_rgb(hex_color: str) -> tuple:
h = (hex_color or '#888888').lstrip('#')
if len(h) == 8:
h = h[2:]
if len(h) != 6:
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:
r, g, b = hex_to_rgb(hex_color)
return (0.299 * r + 0.587 * g + 0.114 * b) > 0.6
_CS_WEEKDAYS = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle']
def format_date_cs(date_str: str) -> str:
"""Format date as 'Pondělí (20.2)'."""
from datetime import date as dt_date
try:
d = dt_date.fromisoformat(date_str)
weekday = _CS_WEEKDAYS[d.weekday()]
return f"{weekday} ({d.day}.{d.month})"
except Exception:
return date_str
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:
norm = total_minutes % 1440
return f"{norm // 60:02d}:{norm % 60:02d}"
def set_fill(c, rgb):
c.setFillColorRGB(*rgb)
def set_stroke(c, rgb):
c.setStrokeColorRGB(*rgb)
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_clipped_text(c, text, x, y, w, h, font, size, rgb, align='center'):
if not text:
return
c.saveState()
p = c.beginPath()
p.rect(x + 1, y, w - 2, h)
c.clipPath(p, stroke=0, fill=0)
set_fill(c, rgb)
c.setFont(font, size)
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, ty, text)
c.restoreState()
def fit_text(c, text: str, font: str, size: float, max_w: float) -> str:
"""Truncate text with ellipsis so it fits within max_w points."""
if not text:
return text
if c.stringWidth(text, font, size) <= max_w:
return text
# Binary-search trim
ellipsis = ''
ellipsis_w = c.stringWidth(ellipsis, font, size)
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if c.stringWidth(text[:mid], font, size) + ellipsis_w <= max_w:
lo = mid
else:
hi = mid - 1
return (text[:lo] + ellipsis) if lo < len(text) else text
def wrap_text(c, text: str, font: str, size: float, max_w: float) -> list:
"""Word-wrap text into lines fitting within max_w points."""
if not text:
return []
words = text.split()
if not words:
return []
lines = []
current = words[0]
for word in words[1:]:
test = current + ' ' + word
if c.stringWidth(test, font, size) <= max_w:
current = test
else:
lines.append(current)
current = word
lines.append(current)
return lines
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}'")
# Collect dates + time range
sorted_dates = doc.get_sorted_dates()
num_days = len(sorted_dates)
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 ────────────────────────────────────────────────────────
x0 = MARGIN
y_top = PAGE_H - MARGIN
# Header block (title + subtitle + info)
TITLE_SIZE = 16
SUB_SIZE = 10
INFO_SIZE = 8
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 + 3
header_h += 4
# 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_H = 10
TIME_AXIS_H = 18
DATE_COL_W = 28 * mm # wider for Czech day names like "Pondělí (20.2)"
# 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))
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/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(_FONT_BOLD, TITLE_SIZE)
set_fill(c, HEADER_BG)
c.drawString(x0, y - TITLE_SIZE, doc.event.title)
y -= TITLE_SIZE + 5
if doc.event.subtitle:
c.setFont(_FONT_REGULAR, SUB_SIZE)
set_fill(c, (0.4, 0.4, 0.4))
c.drawString(x0, y - SUB_SIZE, doc.event.subtitle)
y -= SUB_SIZE + 3
if has_info:
parts = []
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(_FONT_REGULAR, INFO_SIZE)
set_fill(c, (0.5, 0.5, 0.5))
c.drawString(x0, y - INFO_SIZE, ' | '.join(parts))
y -= INFO_SIZE + 3
y -= 4 # padding
# ── 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(_FONT_REGULAR, 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(_FONT_REGULAR, time_axis_font)
set_fill(c, AXIS_TEXT)
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m))
# ── Footnote map: blocks with notes get sequential numbers ────────
footnotes = [] # [(num, block), ...]
footnote_map = {} # block.id → footnote number
for b in sorted(doc.blocks, key=lambda x: (x.date, x.start)):
if b.notes:
num = len(footnotes) + 1
footnotes.append((num, b))
footnote_map[b.id] = num
# ── Draw day rows ─────────────────────────────────────────────────
blocks_by_date = defaultdict(list)
for b in doc.blocks:
blocks_by_date[b.date].append(b)
for di, date_key in enumerate(sorted_dates):
row_y = table_top - (di + 1) * row_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)
# Date label cell
fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4)
draw_clipped_text(c, format_date_cs(date_key), x0, row_y, DATE_COL_W, row_h,
_FONT_BOLD, date_font, AXIS_TEXT, 'center')
# 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)
# Draw program blocks
for block in blocks_by_date[date_key]:
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
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.08, 0.08, 0.08) if is_light(pt.color) else (1.0, 1.0, 1.0)
inset = 1.0
c.saveState()
# 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)
# Draw text clipped to block
p = c.beginPath()
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)
fn_num = footnote_map.get(block.id)
title_text = block.title + ('' if overnight else '')
dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78)
if is_light(pt.color) else (0.82, 0.82, 0.82))
# Available dimensions for text
text_w_avail = max(1.0, bw - 2 * inset - 4)
text_h_avail = row_h - 2 * inset - 2
resp_size = max(4.0, block_time_font)
has_responsible = bool(block.responsible)
MIN_TITLE_FONT = 3.5
# ── Layout decision: horizontal vs vertical ───────────
# When a block is too narrow for any word at a readable
# size, rotate text 90° to leverage the full row height.
MIN_HORIZ_FONT = 5.0
_words = title_text.split()
_longest_word_w = max(
(c.stringWidth(w, _FONT_BOLD, MIN_HORIZ_FONT) for w in _words),
default=0) if _words else 0
use_vertical = _longest_word_w > text_w_avail
if use_vertical:
# ── Vertical text (90° CCW rotation) ──────────────
# Row height becomes text "width"; block width becomes
# stacking "height" for multiple lines.
vert_w = row_h - 2 * inset - 4
vert_h = bw - 2 * inset - 4
resp_h_v = (resp_size + 1) if has_responsible else 0
title_h_v = max(block_title_font, vert_h - resp_h_v)
t_font = block_title_font
t_leading = t_font * 1.15
title_lines = wrap_text(c, title_text, _FONT_BOLD, t_font, vert_w)
def _v_overflow(lines, fnt, sz, lh, h_avail, w_avail):
if len(lines) * lh > h_avail:
return True
return any(c.stringWidth(ln, fnt, sz) > w_avail for ln in lines)
while (_v_overflow(title_lines, _FONT_BOLD, t_font,
t_leading, title_h_v, vert_w)
and t_font > MIN_TITLE_FONT):
t_font = max(MIN_TITLE_FONT, t_font - 0.5)
t_leading = t_font * 1.15
title_lines = wrap_text(c, title_text, _FONT_BOLD,
t_font, vert_w)
# Truncate excess lines at min font
max_vl = max(1, int(title_h_v / t_leading))
if len(title_lines) > max_vl:
kept = title_lines[:max_vl - 1] if max_vl > 1 else []
remaining = ' '.join(title_lines[len(kept):])
kept.append(fit_text(c, remaining, _FONT_BOLD,
t_font, vert_w))
title_lines = kept
# Footnote superscript sizing for vertical mode
v_sup_size = max(3.0, t_font * 0.65)
v_sup_reserve = (c.stringWidth(str(fn_num), _FONT_BOLD,
v_sup_size) + 1.0
if fn_num else 0)
n_lines = len(title_lines)
# Total stacking height (along block width)
content_stack = t_font + (n_lines - 1) * t_leading
if has_responsible:
content_stack += 1 + resp_size
# Rotate: translate to block center, then 90° CCW.
# After rotation: new +X = page up, new +Y = page left.
# drawCentredString(rx, ry) centres text at rx along the
# vertical page axis; ry controls horizontal page offset.
c.translate(bx + bw / 2, row_y + row_h / 2)
c.rotate(90)
# Stack lines left → right (decreasing ry)
first_ry = (content_stack - t_font) / 2
ry = first_ry
for li, line in enumerate(title_lines):
c.setFont(_FONT_BOLD, t_font)
set_fill(c, text_rgb)
if fn_num is not None and li == n_lines - 1:
# Last line: draw title + superscript number
line_w = c.stringWidth(line, _FONT_BOLD, t_font)
lx = -(line_w + v_sup_reserve) / 2
c.drawString(lx, ry, line)
c.setFont(_FONT_BOLD, v_sup_size)
set_fill(c, dim_rgb)
c.drawString(lx + line_w + 0.5,
ry + t_font * 0.45, str(fn_num))
else:
c.drawCentredString(0, ry, line)
ry -= t_leading
if has_responsible:
ry -= 1
fitted_r = fit_text(c, block.responsible,
_FONT_ITALIC, resp_size, vert_w)
c.setFont(_FONT_ITALIC, resp_size)
set_fill(c, dim_rgb)
c.drawCentredString(0, ry, fitted_r)
else:
# ── Horizontal text (normal) ──────────────────────
resp_h = (resp_size + 2) if has_responsible else 0
title_h_avail = max(block_title_font, text_h_avail - resp_h)
# Pre-compute footnote reserve so the shrink loop
# accounts for it — prevents truncation of titles
# that barely fit without the superscript.
def _sup_reserve_for(fsize):
if not fn_num:
return 0
ss = max(3.0, fsize * 0.65)
return c.stringWidth(str(fn_num), _FONT_BOLD, ss) + 1.5
t_font = block_title_font
t_leading = t_font * 1.15
_sr = _sup_reserve_for(t_font)
title_lines = wrap_text(c, title_text, _FONT_BOLD,
t_font, text_w_avail - _sr)
def _h_overflow(lines, fnt, sz, lh, h_avail, w_avail):
if len(lines) * lh > h_avail:
return True
return any(c.stringWidth(ln, fnt, sz) > w_avail
for ln in lines)
while (_h_overflow(title_lines, _FONT_BOLD, t_font,
t_leading, title_h_avail,
text_w_avail - _sr)
and t_font > MIN_TITLE_FONT):
t_font = max(MIN_TITLE_FONT, t_font - 0.5)
t_leading = t_font * 1.15
_sr = _sup_reserve_for(t_font)
title_lines = wrap_text(c, title_text, _FONT_BOLD,
t_font, text_w_avail - _sr)
# Truncate excess lines at min font
max_title_lines = max(1, int(title_h_avail / t_leading))
if len(title_lines) > max_title_lines:
kept = (title_lines[:max_title_lines - 1]
if max_title_lines > 1 else [])
remaining = ' '.join(title_lines[len(kept):])
kept.append(fit_text(c, remaining, _FONT_BOLD,
t_font, text_w_avail - _sr))
title_lines = kept
# Final footnote superscript sizing
sup_size = max(3.0, t_font * 0.65)
sup_reserve = _sup_reserve_for(t_font)
# Vertical centering
n_title = len(title_lines)
content_h = t_font + (n_title - 1) * t_leading
if has_responsible:
content_h += 2 + resp_size
first_baseline = row_y + (row_h + content_h) / 2 - t_font
# Draw title lines (top → bottom)
ty = first_baseline
for i, line in enumerate(title_lines):
c.setFont(_FONT_BOLD, t_font)
set_fill(c, text_rgb)
if fn_num is not None and i == n_title - 1:
line_w = c.stringWidth(line, _FONT_BOLD, t_font)
lx = bx + bw / 2 - (line_w + sup_reserve) / 2
c.drawString(lx, ty, line)
c.setFont(_FONT_BOLD, sup_size)
set_fill(c, dim_rgb)
c.drawString(lx + line_w + 0.5,
ty + t_font * 0.45, str(fn_num))
else:
c.drawCentredString(bx + bw / 2, ty, line)
ty -= t_leading
# Draw responsible person (below title)
if has_responsible:
resp_y = ty - 1
fitted_resp = fit_text(c, block.responsible,
_FONT_ITALIC, resp_size,
text_w_avail)
c.setFont(_FONT_ITALIC, resp_size)
set_fill(c, dim_rgb)
c.drawCentredString(bx + bw / 2, resp_y, fitted_resp)
c.restoreState()
# If block has a URL, make the entire block a clickable link
if getattr(block, 'url', None):
c.linkURL(block.url,
(bx + inset, row_y + inset,
bx + bw - inset, row_y + row_h - inset),
relative=0)
# ── Legend ────────────────────────────────────────────────────────
legend_y_top = table_top - num_days * row_h - 6
c.setFont(_FONT_BOLD, 7)
set_fill(c, HEADER_BG)
c.drawString(x0, legend_y_top, 'Legenda:')
legend_y_top -= LEGEND_ITEM_H
for i, pt in enumerate(doc.program_types):
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)
# 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)
# Type name NEXT TO the square
c.setFont(_FONT_REGULAR, 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 (page 1) ───────────────────────────────────────────────
gen_date = datetime.now().strftime('%d.%m.%Y %H:%M')
c.setFont(_FONT_ITALIC, 6.5)
set_fill(c, FOOTER_TEXT)
footer_note = ' | Poznámky na str. 2' if footnotes else ''
c.drawCentredString(PAGE_W / 2, MARGIN - 2,
f'Vygenerováno Scénář Creatorem | {gen_date}{footer_note}')
# ── Page 2: Poznámky ke scénáři ───────────────────────────────────
if footnotes:
c.showPage()
NOTE_MARGIN = 15 * mm
ny = PAGE_H - NOTE_MARGIN
# Page title
c.setFont(_FONT_BOLD, 14)
set_fill(c, HEADER_BG)
c.drawString(NOTE_MARGIN, ny - 14, 'Poznámky ke scénáři')
ny -= 14 + 4
# Subtitle: event title + date
ev_info = doc.event.title
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:
ev_info += f' | {date_display} {date_to_display}'
else:
ev_info += f' | {date_display}'
c.setFont(_FONT_REGULAR, 9)
set_fill(c, AXIS_TEXT)
c.drawString(NOTE_MARGIN, ny - 9, ev_info)
ny -= 9 + 8
# Separator line
set_stroke(c, GRID_HOUR)
c.setLineWidth(0.5)
c.line(NOTE_MARGIN, ny, PAGE_W - NOTE_MARGIN, ny)
ny -= 8
# Footnote entries
for fn_num, block in footnotes:
# Block header: number + title + day + time
day_label = format_date_cs(block.date)
time_str = f'{block.start}{block.end}'
resp_str = f' ({block.responsible})' if block.responsible else ''
header_text = f'{fn_num}. {block.title}{resp_str}{day_label}, {time_str}'
# Check space, add new page if needed
if ny < NOTE_MARGIN + 30:
c.showPage()
ny = PAGE_H - NOTE_MARGIN
c.setFont(_FONT_BOLD, 9)
set_fill(c, HEADER_BG)
c.drawString(NOTE_MARGIN, ny - 9, header_text)
ny -= 9 + 3
# Note text (wrapped manually)
note_text = block.notes or ''
words = note_text.split()
line_w = PAGE_W - 2 * NOTE_MARGIN - 10
c.setFont(_FONT_REGULAR, 8.5)
set_fill(c, (0.15, 0.15, 0.15))
line = ''
for word in words:
test_line = (line + ' ' + word).strip()
if c.stringWidth(test_line, _FONT_REGULAR, 8.5) > line_w:
if ny < NOTE_MARGIN + 15:
c.showPage()
ny = PAGE_H - NOTE_MARGIN
c.drawString(NOTE_MARGIN + 8, ny - 8.5, line)
ny -= 8.5 + 2
line = word
else:
line = test_line
if line:
if ny < NOTE_MARGIN + 15:
c.showPage()
ny = PAGE_H - NOTE_MARGIN
c.drawString(NOTE_MARGIN + 8, ny - 8.5, line)
ny -= 8.5 + 2
ny -= 5 # spacing between footnotes
# Footer on notes page
c.setFont(_FONT_ITALIC, 6.5)
set_fill(c, FOOTER_TEXT)
c.drawCentredString(PAGE_W / 2, MARGIN - 2,
f'Poznámky ke scénáři — {doc.event.title} | {gen_date}')
c.save()
return buf.getvalue()