#!/usr/bin/env python3 """PDF-версия презентации «Автодозвон: как это работает» (тёмная тема, 16:9). Содержание — из deck_content.py (то же, что в HTML-версии), здесь только вёрстка. Текст, который не влезает в карточку, считается ошибкой: скрипт падает, а не рисует поверх границ. Запуск: python3 docs/autocall/how-it-works.py """ from pathlib import Path from reportlab.lib.colors import Color, HexColor from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas import deck_content as content FONT_DIR = Path("/usr/share/fonts/truetype/dejavu") OUT = Path(__file__).with_name("autocall-how-it-works.pdf") W, H = 960, 540 M = 46 BG = HexColor("#0B1220") BG2 = HexColor("#101A2C") CARD = HexColor("#131E33") BORDER = HexColor("#25344F") TEXT = HexColor("#E8EEF9") DIM = HexColor("#9FB0CC") TONES = { "accent": HexColor("#4ADE80"), "accent2": HexColor("#60A5FA"), "warn": HexColor("#FBBF24"), "danger": HexColor("#F87171"), } ACCENT = TONES["accent"] pdfmetrics.registerFont(TTFont("DV", str(FONT_DIR / "DejaVuSans.ttf"))) pdfmetrics.registerFont(TTFont("DV-B", str(FONT_DIR / "DejaVuSans-Bold.ttf"))) c = canvas.Canvas(str(OUT), pagesize=(W, H)) c.setTitle("Автодозвон SuperSam — как это работает") c.setAuthor("SuperSam") slide_no = 0 CONTENT_W = W - 2 * M def tone(name): return TONES.get(name, ACCENT) def wrap(text, font, size, max_w): words, lines, cur = text.split(), [], "" for word in words: probe = f"{cur} {word}".strip() if pdfmetrics.stringWidth(probe, font, size) <= max_w: cur = probe else: if cur: lines.append(cur) cur = word if cur: lines.append(cur) return lines def bg(): c.setFillColor(BG) c.rect(0, 0, W, H, stroke=0, fill=1) c.setFillColor(BG2) c.rect(0, H - 120, W, 120, stroke=0, fill=1) c.setFillColor(Color(0.29, 0.85, 0.5, alpha=0.06)) c.circle(W - 90, H - 40, 150, stroke=0, fill=1) def footer(): global slide_no slide_no += 1 c.setFont("DV", 9) c.setFillColor(DIM) c.drawString(M, 22, "Автодозвон SuperSam · как это работает") c.drawRightString(W - M, 22, f"{slide_no}") c.setStrokeColor(BORDER) c.setLineWidth(0.5) c.line(M, 36, W - M, 36) def header(title, sub=None, kicker=None): y = H - 60 if kicker: c.setFont("DV-B", 10) c.setFillColor(ACCENT) c.drawString(M, y + 22, kicker.upper()) c.setFont("DV-B", 26) c.setFillColor(TEXT) c.drawString(M, y - 12, title) if sub: c.setFont("DV", 12) c.setFillColor(DIM) c.drawString(M, y - 34, sub) def card(x, y, w, h, accent=None, fill=CARD): c.setFillColor(fill) c.setStrokeColor(accent or BORDER) c.setLineWidth(1) c.roundRect(x, y, w, h, 12, stroke=1, fill=1) def block_size(items, w, size=12, lead=18, title_size=14): """Сколько места займёт карточка: список строк и высота.""" wrapped = [wrap(item, "DV", size, w - 44) for item in items] lines = sum(len(w_) for w_ in wrapped) height = 50 + lines * lead + len(items) * 4 return wrapped, height def draw_block(x, y, w, h, title, items, accent=ACCENT, size=12, lead=18, title_size=14): wrapped, _ = block_size(items, w, size, lead, title_size) card(x, y - h, w, h, accent=accent) c.setFont("DV-B", title_size) c.setFillColor(accent) c.drawString(x + 16, y - 26, title) cursor = y - 48 for wrapped_item in wrapped: for i, line in enumerate(wrapped_item): if i == 0: c.setFillColor(accent) c.circle(x + 20, cursor + 4, 2.4, stroke=0, fill=1) c.setFont("DV", size) c.setFillColor(TEXT) c.drawString(x + 32, cursor, line) cursor -= lead cursor -= 4 if cursor < (y - h) - 2: raise AssertionError(f"текст вышел за карточку «{title}»") return y - h def table_measure(rows, widths, size=11, lead=19): """Высота таблицы — чтобы можно было отцентровать блок по слайду.""" h = 0 for row in rows: lines = max(len(wrap(str(cell), "DV", size, cw - 16)) for cell, cw in zip(row, widths)) h += lead + 8 + (lines - 1) * (lead - 4) return h def cards_row(cards, y, total_w=CONTENT_W, gap=14): """Ряд карточек одинаковой высоты; возвращает нижнюю границу ряда.""" n = len(cards) w = (total_w - gap * (n - 1)) / n heights = [block_size(card_["items"], w)[1] for card_ in cards] h = max(heights) x = M for card_ in cards: draw_block(x, y, w, h, card_["title"], card_["items"], accent=tone(card_.get("accent"))) x += w + gap return y - h def table(rows, widths, y, size=11, lead=19, head=True): """Таблица; возвращает нижнюю границу.""" for r_i, row in enumerate(rows): lines_per_cell = [wrap(str(cell), "DV", size, cw - 16) for cell, cw in zip(row, widths)] h = lead + 8 + (max(len(l) for l in lines_per_cell) - 1) * (lead - 4) if head and r_i == 0: card(M, y - h, CONTENT_W, h, accent=TONES["accent2"], fill=HexColor("#182640")) cx = M for (cell, cw), cell_lines in zip(zip(row, widths), lines_per_cell): if head and r_i == 0: cx += cw continue for i, line in enumerate(cell_lines): c.setFont("DV", size) c.setFillColor(DIM if (i == 0 and cx == M) else TEXT) c.drawString(cx + 8, y - 21 - i * (lead - 4), line) cx += cw if head and r_i == 0: cx = M for cell, cw in zip(row, widths): c.setFont("DV-B", size) c.setFillColor(TEXT) c.drawString(cx + 8, y - 21, str(cell)) cx += cw y -= h return y def steps_row(steps, y): n = len(steps) gap = 14 w = (CONTENT_W - gap * (n - 1)) / n h = 170 x = M for i, (title, desc) in enumerate(steps): accent = ACCENT if i < n - 1 else TONES["accent2"] card(x, y - h, w, h, accent=accent) c.setFont("DV-B", 12) c.setFillColor(accent) c.drawString(x + 14, y - 34, title) c.setFont("DV", 11) c.setFillColor(TEXT) line_y = y - 58 for raw_line in desc.split("\n"): for line in wrap(raw_line, "DV", 11, w - 28): c.drawString(x + 14, line_y, line) line_y -= 17 if i < n - 1: c.setFillColor(DIM) c.setFont("DV-B", 16) c.drawString(x + w + 1, y - 72, "›") x += w + gap return y - h def render(slide): bg() if slide["kind"] == "title": c.setFont("DV-B", 11) c.setFillColor(ACCENT) c.drawString(M, H - 150, slide["kicker"]) c.setFont("DV-B", 40) c.setFillColor(TEXT) c.drawString(M, H - 210, slide["title"]) c.setFont("DV", 15) c.setFillColor(DIM) c.drawString(M, H - 244, slide["sub"]) card(M, 120, CONTENT_W, 150, accent=ACCENT) for i, line in enumerate(slide["lines"]): c.setFont("DV", 13) c.setFillColor(TEXT if line else DIM) c.drawString(M + 18, 240 - i * 20, line) footer() c.showPage() return header(slide["title"], slide.get("sub")) kind = slide["kind"] full = slide.get("full") # Измеряем содержимое и центруем блок по вертикали: иначе внизу слайда # остаётся пустая полоса, а на другом слайде текст поджимает футер. if kind == "steps": main_h = 170 elif kind in ("cards2full", "cards3full"): n = len(slide["cards"]) w = (CONTENT_W - 14 * (n - 1)) / n main_h = max(block_size(card_["items"], w)[1] for card_ in slide["cards"]) elif kind == "tablecard": main_h = table_measure(slide["table"]["rows"], slide["table"]["widths"]) else: raise AssertionError(f"неизвестный тип слайда: {kind}") full_h = block_size(full["items"], CONTENT_W)[1] if full else 0 gap = 22 if full else 0 top, bottom_limit = H - 96, 56 total = main_h + (gap + full_h if full else 0) y0 = top - max(0.0, (top - bottom_limit - total) / 2) if kind == "steps": bottom = steps_row(slide["steps"], y0) elif kind in ("cards2full", "cards3full"): bottom = cards_row(slide["cards"], y0) else: bottom = table(slide["table"]["rows"], slide["table"]["widths"], y0) if full: y = bottom - gap if y - full_h < 44: raise AssertionError(f"нижняя карточка не влезает на слайде «{slide['title']}»") draw_block(M, y, CONTENT_W, full_h, full["title"], full["items"], accent=tone(full.get("accent"))) footer() c.showPage() for slide_ in content.ALL_SLIDES: render(slide_) c.save() print(f"готово: {OUT} — слайдов {slide_no}") print(f"вёрстка проверена: все карточки по размеру текста (проверено {slide_no} слайдов)")