284 lines
9.5 KiB
Python
284 lines
9.5 KiB
Python
#!/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} слайдов)")
|