Some checks failed
Build & Push Docker / build (push) Has been cancelled
- 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
739 lines
28 KiB
Python
739 lines
28 KiB
Python
"""
|
||
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()
|