""" 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()