"""PDF-генератор для Trade-In Estimator — собственная визуальная система GenDesign/Mera.
Дизайн опирается на живой веб-продукт (tradein-mvp/frontend/.../trade-in.css):
цветовая палитра сконвертирована из OKLCH-токенов веб-приложения в hex через
точную OKLab→sRGB матрицу (Ottosson), типографика — PT Serif (заголовки страниц,
только жирное начертание), PT Sans (текст/таблицы) — оба встроены в PDF как
base64 @font-face (SIL OFL 1.1, см. app/services/exporters/fonts/), с DejaVu
Serif/Sans как fallback-цепочкой; DejaVu Sans Mono (все числовые значения,
tabular-nums) остаётся системным — из пакета fonts-dejavu-core, гарантированно
установленного в Docker-образе. Диапазоны цен (_price_range_chart_svg) — реальный
matplotlib-график (Agg, SVG), не декоративный CSS-div.
Документ — деловой/утилитарный отчёт (ближе к банковской оценке, чем к
маркетинговому лендингу), НЕ 1:1 копия конкурентного референса.
Структура отчёта — 4 страницы:
1. Cover — № отчёта + параметры объекта + 2 диапазона цен + 3 блока «Что важно»
2. Listings — рынок квартир-аналогов по объявлениям + таблица примеров
3. Deals — фактические сделки + таблица примеров
4. Offer — формирование выкупной стоимости (Trade-In vs самопродажа) + 4 преимущества
White-label через Brand: PRINZIP / Практика / generic (см. app/services/brand.py).
Расширения: source badges + дистанция + кликабельные ссылки.
"""
from __future__ import annotations
import base64
import datetime as dt
import html as _html
import io
import logging
import pathlib
import re
from urllib.parse import urlparse
from uuid import UUID
import matplotlib
matplotlib.use("Agg") # headless-safe backend — ОБЯЗАТЕЛЬНО до любого рендера: в Docker-образе
# нет GUI toolkit (Tkinter/Qt/GTK), импорт matplotlib без явного backend может тянуть их/падать.
import matplotlib.font_manager as _font_manager
import segno
from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price_range_chart_svg
from matplotlib.patches import Rectangle
from app.core.config import settings
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
logger = logging.getLogger(__name__)
# ── URL allowlist (C-6 security audit) ─────────────────────────────────────
# Только доверенные домены-источники объявлений попадают в в PDF.
# Защита от javascript: / data: инъекций через source_url объявлений.
_ALLOWED_URL_DOMAINS: frozenset[str] = frozenset(
{
"www.avito.ru",
"avito.ru",
"m.avito.ru",
"www.cian.ru",
"cian.ru",
"realty.yandex.ru",
}
)
# CDN-домены для изображений аналогов (photo_url).
_ALLOWED_PHOTO_CDN: frozenset[str] = frozenset(
{
"images.avito.st",
"cdn-p.cian.site",
"avatars.mds.yandex.net",
}
)
def _safe_url(url: str | None, *, cdn: bool = False) -> str | None:
"""Проверяет URL по allowlist схемы и домена.
cdn=False — allowlist для source_url объявлений (ссылки на листинги).
cdn=True — allowlist для photo_url (CDN изображений).
Возвращает None если URL не прошёл проверку — embed/ссылка пропускается.
"""
if not url:
return None
try:
p = urlparse(url)
except Exception:
return None
if p.scheme != "https":
return None
allowed = _ALLOWED_PHOTO_CDN if cdn else _ALLOWED_URL_DOMAINS
if p.netloc not in allowed:
return None
return url
# ── Logo URL и color-sanitation (#13 SSRF / CSS-injection guard) ─────────────
# Допустимые домены для логотипов брендов (white-label).
_ALLOWED_LOGO_CDN: frozenset[str] = frozenset(
{
"gk-praktika.ru",
"www.gk-praktika.ru",
"prinzip.ru",
"www.prinzip.ru",
"cdn.gk-praktika.ru",
}
)
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
def _safe_logo_url(url: str | None) -> str | None:
"""Валидирует logo_url бренда: только https:// + allowlist домен + не data:/file:.
data: URI допускаются для инлайн-логотипов (нет сетевого запроса).
Всё остальное отбрасывается. Если передан file:// — отбрасывается.
"""
if not url:
return None
try:
p = urlparse(url)
except Exception:
return None
if p.scheme == "data":
# data: URI безопасны — нет SSRF, только инлайн
return url
if p.scheme != "https":
# file://, http://, javascript: — все отбрасываем
return None
if p.netloc not in _ALLOWED_LOGO_CDN:
logger.warning("logo_url domain not in allowlist: %s", p.netloc)
return None
return url
def _safe_color(color: str | None, fallback: str) -> str:
"""Принимает только #RRGGBB hex. Всё остальное → fallback.
Защита от CSS-injection через primary_color/accent_color бренда
(WeasyPrint вставляет значение напрямую в style=).
"""
if color and _COLOR_RE.match(color):
return color
return fallback
# ── WeasyPrint custom url_fetcher (SSRF guard) ───────────────────────────────
def _make_safe_url_fetcher(): # type: ignore[no-untyped-def]
"""Возвращает url_fetcher для WeasyPrint, блокирующий file:// и нелоговые схемы.
Разрешены: https:// (логотипы + шрифты CDN) и data: (инлайн SVG/PNG).
Блокированы: file://, http://, ftp:// и любые другие.
"""
def fetcher(url: str, timeout: int = 10, ssl_context=None): # type: ignore[no-untyped-def]
# Импорт отложен до момента первого реального fetch, чтобы тесты могли
# подменять weasyprint.urls через sys.modules без ModuleNotFoundError.
from weasyprint.urls import default_url_fetcher
try:
p = urlparse(url)
except Exception:
raise ValueError(f"Invalid URL in PDF resource: {url!r}") from None
if p.scheme in ("data", "https"):
return default_url_fetcher(url, timeout=timeout, ssl_context=ssl_context)
raise ValueError(
f"PDF resource scheme '{p.scheme}' is not allowed (only data: and https:). URL: {url!r}"
)
return fetcher
# ── Design tokens (design system) ────────────────────────────────────────────
# Цвета сконвертированы из OKLCH-токенов живого веб-продукта
# (tradein-mvp/frontend/src/components/trade-in/trade-in.css) точной OKLab→sRGB
# матрицей (Ottosson reference), а не приблизительно на глаз. --muted/--success
# в исходнике веба уже hex — взяты как есть, без конверсии.
_FG = "#0e1217" # --fg: oklch(18% 0.012 250) — основной текст/чернила
_BG = "#fbfcfd" # --bg: oklch(99% 0.002 240) — чуть холодный near-white, не чистый #fff
_MUTED = "#5f6570" # --muted (web, уже hex) — вторичный текст/лейблы
_ACCENT = "#1b6ad4" # --accent: oklch(54% 0.18 258) — базовый синий (non-white-label)
_ACCENT_SOFT = "#e6f3ff" # oklch(96% 0.03 258)
_ACCENT_2 = "#da6600" # --accent-2: oklch(64% 0.17 50) — тёплый вторичный (был generic amber)
_ACCENT_2_SOFT = "#ffedce" # oklch(96% 0.05 70)
_DANGER = "#d02b31" # --danger: oklch(56% 0.20 25) — был generic bootstrap-red #dc2626
_DANGER_SOFT = "#ffe8e4" # oklch(96% 0.04 25)
_SUCCESS = "#197038" # --success (web, уже hex) — «бесплатно» / экономия
_BORDER = "#e2e5e8" # --border: oklch(92% 0.005 250) — hairline (был произвольный #e6e8ec)
_BORDER_STRONG = "#babec3" # --border-strong: oklch(80% 0.008 250)
_CARD_BG = "#f4f7f9" # --surface-2: oklch(97.5% 0.004 240) — карточка (был #f9fafb)
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
_FS_SM = "9pt" # labels, заголовки таблиц, explainer-текст
_FS_MD = "10.5pt" # базовый текст (body), значения в таблицах
_FS_LG = "13pt" # заголовки страниц (h2, PT Serif)
_FS_XL = "16pt" # главный заголовок cover (h1, PT Serif)
_FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок)
# ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ──────────────
# Раньше CSS декларировал 'DejaVu Serif'/'DejaVu Sans' — это системные fallback-шрифты
# без гарантированного визуального качества (только моноширинный 'DejaVu Sans Mono'
# для цифр остаётся — не часть этой замены, см. _mono()). Теперь настоящие PT Sans/
# PT Serif встроены как base64 @font-face (см. _build_css) — файлы читаются и
# кодируются ОДИН раз при импорте модуля, не на каждый рендер PDF.
_FONTS_DIR = pathlib.Path(__file__).parent / "fonts"
def _font_data_url(filename: str) -> str:
"""Читает .ttf из fonts/ (рядом с этим модулем) и возвращает data:font/ttf;base64 URI."""
data = (_FONTS_DIR / filename).read_bytes()
return f"data:font/ttf;base64,{base64.b64encode(data).decode('ascii')}"
_PT_SANS_REGULAR_URL = _font_data_url("PTSans-Regular.ttf")
_PT_SANS_BOLD_URL = _font_data_url("PTSans-Bold.ttf")
_PT_SERIF_BOLD_URL = _font_data_url("PTSerif-Bold.ttf")
# Регистрируем те же файлы в matplotlib (chart endpoint-labels используют тот же
# PT Sans, что и остальной документ — единая типографика, tabular-figure цифры).
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Regular.ttf"))
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Bold.ttf"))
matplotlib.rcParams["font.family"] = "PT Sans"
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо)
"cian": ("#0468ff", "#fff"), # Циан фирменный синий
"domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер
"yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный
"rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий
"etazhi": ("#e30613", "#fff"), # Этажи красный
}
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
"avito": "Avito",
"cian": "Циан",
"domklik": "Домклик · Сбер",
"yandex": "Я.Недвижимость",
"rosreestr": "Росреестр",
"etazhi": "Этажи",
}
def _source_logo_pill(source: str) -> str:
"""Pill-badge с цветом источника."""
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"{_html.escape(name)}"
)
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона)."""
if not source:
return f"—"
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"{_html.escape(name)}"
)
# ── Helpers ──────────────────────────────────────────────────────────────────
_NBSP = "\u00a0" # неразрывный пробел — разряды/валюта не переносятся посередине
def _fmt_rub(value: int) -> str:
"""12 500 000 ₽ — неразрывные пробелы, чтобы число никогда не переносилось
посередине (разряды/символ валюты не должны сиротеть на разных строках)."""
return f"{value:,}".replace(",", _NBSP) + _NBSP + "₽"
def _fmt_rub_m(value: int) -> str:
"""3.4 млн. руб."""
m = value / 1_000_000
return f"{m:.1f}".replace(".", ",") + " млн. руб."
def _fmt_ppm2(value: int) -> str:
"""101 176"""
return f"{value:,}".replace(",", " ")
def _mono(text: str) -> str:
"""Оборачивает числовой текст (цены/даты/%/№ отчёта/м²/₽-м²/срок экспозиции)
в моноширинный span — DejaVu Sans Mono + tabular-nums (см. .mono в _build_css),
зеркалит web-конвенцию .num/.mono из trade-in.css (HeroSummary.tsx и др.)."""
return f"{text}"
def _report_number(estimate_id: UUID) -> str:
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
short = int(estimate_id.int) % 10_000
long = int(estimate_id.int) % 10_000_000_000
return f"EKБ-{short:04d}-{long:010d}"
def _qr_code_data_url(text: str, size: int = 4) -> str:
"""QR-код как SVG data URL."""
qr = segno.make(text, error="m")
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff")
return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
def _conf_label(confidence: str) -> str:
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
# ── Unified page header (cover + pages 2-4 использовали визуально разные
# паттерны — сплошная цветная плашка vs текстовая полоска; теперь один блок) ──
def _page_header(brand) -> str: # type: ignore[no-untyped-def]
"""Логотип/имя бренда + акцентная линия снизу — единый паттерн для всех 4 страниц.
brand.primary_color по-прежнему управляет цветом линии/плашки (white-label
механизм не тронут) — только markup унифицирован.
"""
logo_url = _safe_logo_url(brand.logo_url)
mark_html = (
f""
if logo_url
else f"{_html.escape(brand.name).upper()}"
)
return (
f'
— WeasyPrint рендерит SVG только через image
# pipeline (не как inline-разметку), см. reference prototype build_report.py.
def _price_range_chart_svg(
range_low: int,
range_high: int,
*,
label_left: str | None = None,
label_right: str | None = None,
sub_label: str = "Рынок",
days_min: int | None = None,
days_max: int | None = None,
show_days: bool = False,
accent: str = _ACCENT,
) -> str:
"""Bullet/range-chart диапазона цен (matplotlib Agg → SVG → base64
).
Подложка-трек (светло-серый) + закрашенная акцентная зона на реальном диапазоне
[range_low, range_high] + акцентные end-tick'и + подписи-концы + опционально
срок экспозиции (show_days=True) — та же информация, что раньше показывал
_price_range_bar (удалена), но настоящий график, а не декоративный div."""
if range_high <= range_low:
return ""
label_left = label_left or _fmt_rub_m(range_low)
label_right = label_right or _fmt_rub_m(range_high)
lo_m = range_low / 1_000_000
hi_m = range_high / 1_000_000
pad = (hi_m - lo_m) * 0.08 or 0.3
y = 0.5
track_h = 0.34
# Высота figure (points) задаёт только масштаб track/pad — реальная высота
# итогового SVG определяется bbox_inches="tight" (см. savefig ниже) по фактическому
# содержимому, включая подписи ниже track. Подписи — через ax.annotate с
# textcoords="offset points": абсолютный точечный отступ, НЕ зависящий от масштаба
# data-координат/figsize (в отличие от прежней data-fraction схемы, которая на
# маленьком figsize схлопывала строки label/days друг в друга).
# Figure() object API (НЕ pyplot.subplots) — write_pdf() выполняется в FastAPI
# threadpool-executor (sync endpoint), где несколько запросов на PDF могут рендерить
# график конкурентно; pyplot держит глобальный figure-manager, не потокобезопасный
# для такого сценария (см. matplotlib FAQ "Embedding in a web application server").
# Figure() не регистрируется нигде глобально — никакой утечки, plt.close() не нужен.
fig = Figure(figsize=(6.85, 28 / 72), dpi=200)
ax = fig.subplots()
fig.patch.set_alpha(0)
ax.set_facecolor("none")
# Axes растянуты на всю figure — предсказуемая точка отсчёта для track_bottom.
ax.set_position((0, 0, 1, 1))
track_bottom = y - track_h / 2
# подложка-трек — на всю область построения (визуальный "инструмент")
ax.add_patch(
Rectangle(
(lo_m - pad, track_bottom),
(hi_m - lo_m) + 2 * pad,
track_h,
facecolor=_BORDER,
edgecolor="none",
zorder=1,
)
)
# закрашенная акцентная зона — реальный диапазон range_low..range_high
ax.add_patch(
Rectangle(
(lo_m, track_bottom),
hi_m - lo_m,
track_h,
facecolor=accent,
edgecolor="none",
zorder=2,
)
)
# end-tick'и по границам активной зоны (bullet-chart акцент)
for x in (lo_m, hi_m):
ax.plot(
[x, x],
[track_bottom - 0.04, y + track_h / 2 + 0.04],
color=accent,
linewidth=2.2,
solid_capstyle="round",
zorder=3,
)
# Размер подписей выровнен с типографской шкалой документа (_FS_MD/_FS_XS) —
# тот же масштаб, что раньше показывал div-бар.
for x, label, ha in ((lo_m, label_left, "left"), (hi_m, label_right, "right")):
ax.annotate(
label,
xy=(x, track_bottom),
xycoords="data",
xytext=(0, -4),
textcoords="offset points",
color=_FG,
fontsize=10.5,
ha=ha,
va="top",
fontweight="bold",
annotation_clip=False,
)
if show_days and days_min is not None and days_max is not None:
for x, days, ha in ((lo_m, days_min, "left"), (hi_m, days_max, "right")):
ax.annotate(
f"{days} дней",
xy=(x, track_bottom),
xycoords="data",
xytext=(0, -19),
textcoords="offset points",
color=_DANGER,
fontsize=8,
ha=ha,
va="top",
fontweight="bold",
annotation_clip=False,
)
ax.set_xlim(lo_m - pad, hi_m + pad)
ax.set_ylim(0, 1)
ax.axis("off")
buf = io.BytesIO()
fig.savefig(buf, format="svg", transparent=True, bbox_inches="tight", pad_inches=0.03)
chart_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return (
f'
'
f"
' "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. " "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП " "Росреестра, обычно ниже запроса." "
" ) return ( f'" f"{_html.escape(brand.pdf_disclaimer)}
" ) return f"""| № отчета | {_mono(report_num)} |
| Дата отчета | {_mono(today.strftime("%d.%m.%Y"))} |
| Срок действия данных | до {_mono(expires.strftime("%d.%m.%Y"))} |
| Адрес | {address} |
| Год постройки | {_mono(str(year_built))} |
| Тип дома | {house_label} |
| Этаж / этажность | {_mono(f"{floor} / {total_floors}")} |
| Площадь | {_mono(f"{area} м²")} |
| Планировка | {rooms_label} |
| Состояние ремонта | {repair_label} |
| Балкон / лоджия | {balcony_label} |
Диапазон цен в объявлениях (без учета ремонта)
{listings_bar}Диапазон цен по фактическим сделкам
{deals_bar}| Цены в объявлениях — ожидания собственников | Фактические сделки проходят на 10–18% ниже, что подтверждают Росреестр, ДомКлик и продажи агентств недвижимости |
| Ремонт оценивается по состоянию, а не по вложенным суммам | Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры |
| Неочевидные расходы при самостоятельной продаже | При самостоятельной продаже суммарные расходы могут достигать до 15% стоимости квартиры (торг, риелтор, нотариус, справки) |
Этот отчёт онлайн: {settings.public_url}?id={estimate.estimate_id}
{disclaimer_html}
Источники данных
{sources_html}
|
|
Диапазон цен в объявлениях
{range_bar}| Адрес квартиры | Источник | Стоимость 1 м², руб. | Стоимость объекта, руб. | Срок экспозиции, дней |
|---|
Источники данных
{sources_html}
|
|
Диапазон цен по фактическим сделкам
{range_bar}| Адрес квартиры | Источник | Стоимость 1 м², руб. | Стоимость объекта, руб. | Срок экспозиции, дней |
|---|
| {trade_in_label} | Самостоятельная продажа, руб. | |
|---|---|---|
|
Торг потенциальных покупателей
при стоимости квартиры в объявлении
{_mono(_fmt_rub(listing_price))}
|
Не применимо |
от {_mono(f"{_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}")} {_mono(f"от {torg_pct_low}–{torg_pct_high}%")} |
|
Услуги риэлтора при продаже квартиры
с минимальным торгом за
{_mono(_fmt_rub(sold_price))}
|
бесплатно |
{_mono(f"{_fmt_rub(rieltor_low)} – {_fmt_rub(rieltor_high)}")} {_mono(f"от {rieltor_pct_low}–{rieltor_pct_high}%")} |
|
Аренда после сделки
однокомнатной квартиры на 3 месяца
|
бесплатно | {_mono(f"{_fmt_rub(rent_low)} – {_fmt_rub(rent_high)}")} |
|
Юридическое сопровождение
Проверка документов и подготовка
договоров
|
бесплатно | от {_mono(f"{_fmt_rub(juridical_low)} – {_fmt_rub(juridical_high)}")} |
|
Расходы на рекламу
Ежемесячное базовое продвижение объекта
на Циан, Авито, Я.Недвижимости
|
бесплатно |
от {_mono(f"{_fmt_rub(ads_low)} – {_fmt_rub(ads_high)}")} за 3 месяца, ориентировочно |
| Общие финансовые потери | от {_mono(f"{_fmt_rub(total_low)} – {_fmt_rub(total_high)}")} |
№ {_mono(report_num)} · {_mono(today.strftime("%d.%m.%Y"))} · {address}
По данному объекту не найдено достаточного количества аналогов для формирования надёжной рыночной оценки. Пожалуйста, уточните параметры или обратитесь к специалисту.