Files
scenar-creator/app/core/pdf_generator.py
2026-02-20 19:11:05 +01:00

560 lines
20 KiB
Python
Raw Permalink 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 ─────────────────────────────────────────────────────────
# LiberationSans supports Czech diacritics. Fallback to Helvetica if not found.
_FONT_REGULAR = 'Helvetica'
_FONT_BOLD = 'Helvetica-Bold'
_FONT_ITALIC = 'Helvetica-Oblique'
_LIBERATION_PATHS = [
'/usr/share/fonts/truetype/liberation',
'/usr/share/fonts/liberation',
'/usr/share/fonts/truetype',
]
def _find_font(filename: str):
for base in _LIBERATION_PATHS:
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
regular = _find_font('LiberationSans-Regular.ttf')
bold = _find_font('LiberationSans-Bold.ttf')
italic = _find_font('LiberationSans-Italic.ttf')
if regular and bold and italic:
try:
pdfmetrics.registerFont(TTFont('LiberationSans', regular))
pdfmetrics.registerFont(TTFont('LiberationSans-Bold', bold))
pdfmetrics.registerFont(TTFont('LiberationSans-Italic', italic))
_FONT_REGULAR = 'LiberationSans'
_FONT_BOLD = 'LiberationSans-Bold'
_FONT_ITALIC = 'LiberationSans-Italic'
logger.info('PDF: Using LiberationSans (Czech diacritics supported)')
except Exception as e:
logger.warning(f'PDF: Font registration failed: {e}')
else:
logger.warning('PDF: LiberationSans not 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 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)
set_fill(c, text_rgb)
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 width for text (inset + 2pt padding each side)
text_w_avail = max(1.0, bw - 2 * inset - 4)
sup_size = max(4.0, block_title_font * 0.65)
resp_size = max(4.0, block_time_font)
# Truncate title to fit (leave room for superscript number)
sup_reserve = (c.stringWidth(str(fn_num), _FONT_BOLD, sup_size) + 1.5
if fn_num else 0)
fitted_title = fit_text(c, title_text, _FONT_BOLD, block_title_font,
text_w_avail - sup_reserve)
# Determine vertical layout: how many lines fit?
has_responsible = bool(block.responsible)
if has_responsible and row_h >= block_title_font + resp_size + 3:
# Two-line: title + responsible
title_y = row_y + row_h * 0.55
resp_y = row_y + row_h * 0.55 - block_title_font - 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)
else:
# Single line: title centred
title_y = row_y + (row_h - block_title_font) / 2
# Title
c.setFont(_FONT_BOLD, block_title_font)
set_fill(c, text_rgb)
if fn_num is not None:
# Draw title then superscript footnote number
title_w = c.stringWidth(fitted_title, _FONT_BOLD, block_title_font)
tx = bx + bw / 2 - (title_w + sup_reserve) / 2
c.drawString(tx, title_y, fitted_title)
c.setFont(_FONT_BOLD, sup_size)
set_fill(c, dim_rgb)
c.drawString(tx + title_w + 0.5, title_y + block_title_font * 0.45,
str(fn_num))
else:
c.drawCentredString(bx + bw / 2, title_y, fitted_title)
c.restoreState()
# ── 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()