"""PDF-генератор для Trade-In Estimator — собственная визуальная система GenDesign/Mera. Дизайн-токены (цвет / HUD-хром: header/footer/CSS) портированы из РЕАЛЬНОЙ Mera v2 HUD/blueprint-идентичности — canonical source: tradein-mvp/frontend/src/components/trade-in/v2/tokens.ts (+ Footer.tsx/HeroBar.tsx/ ObjectSummary.tsx для composition-паттернов). Два предыдущих раунда редизайна (PR #2327, #2427) ошибочно опирались на trade-in.css/tradein.html — старую "PRINZIP"-брендированную внутреннюю демку, НЕ настоящую Mera-идентичность; этот модуль на неё больше не ссылается (см. _PAGE_BG и соседние константы ниже). Типографика — 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-образе. Manrope/IBM Plex Mono (тоже base64 @font-face) — Mera v2 шрифтовая пара, используется в HUD-хроме (_page_header/_page_footer); тело отчёта (_build_cover/_build_listings_page/_build_deals_page/_build_offer_page) на них пока не переключено — следующий этап. Диапазоны цен (_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 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 (Mera v2 HUD design system) ─────────────────────────────── # Портировано 1:1 из канонического токен-контракта /trade-in/v2: # tradein-mvp/frontend/src/components/trade-in/v2/tokens.ts # Предыдущие раунды (PR #2327/#2427) ошибочно брали этот блок из trade-in.css / # tradein.html — несвязанной "PRINZIP"-брендированной внутренней демки, НЕ # настоящей Mera-идентичности продукта. Это исправленный источник. # # Константы, помеченные "legacy alias", НЕ часть контракта tokens.ts — они # существуют только потому, что тело отчёта (_build_cover/_build_listings_page/ # _build_deals_page/_build_offer_page — не тронуто в этом этапе, следующий этап) # по-прежнему ссылается на старые имена; их ЗНАЧЕНИЯ указывают на ближайший # Mera-эквивалент, чтобы весь документ (старое тело + новый HUD-хром) читался # как одна согласованная палитра, а не два конфликтующих во время переходного # многоэтапного редизайна. _PAGE_BG = "#dde6ef" # tokens.pageBg — фон страницы _INK = "#1c2c40" # tokens.ink — заголовки / основной текст _INK_2 = "#17263a" # tokens.ink2 — вторичный ink _BODY = "#39506a" # tokens.body — текст тела _BODY_2 = "#46586c" # tokens.body2 — текст тела, более выразительный (wordmark футера) _MUTED = "#586777" # tokens.muted — самый выразительный приглушённый (лейблы/подписи) _MUTED_2 = "#5c6672" # tokens.muted2 — заголовки карточек / meta-лейблы (header/footer) _LINE = "#c2d3e3" # tokens.line — базовый hairline/бордер _LINE_2 = "#c8d7e6" # tokens.line2 — бордер карточки _LINE_SOFT = "#d3dfeb" # tokens.lineSoft — вертикальные hairline (meta-cell разделители) _LINE_DOTTED = "#cbd7e3" # tokens.lineGradient — пунктирные row-разделители / центр футера _ACCENT = "#2e8bff" # tokens.accent _ACCENT_DEEP = "#0d6fd6" # tokens.accentDeep _ACCENT_LITE = "#46a8ff" # tokens.accentLite _SUCCESS = "#1baa6b" # tokens.success _SUCCESS_BORDER = "#b6e0cc" # tokens.successBorder _WARN = "#b58a3e" # tokens.warn _DANGER = "#cd6868" # tokens.danger _BADGE_TINT = "#eaf1f8" # tokens.badgeTint — непрозрачная бледно-синяя заливка чипа _CARD_BG = "#eef3f7" # flattened tokens.surface.w50 поверх _PAGE_BG — у WeasyPrint нет # backdrop-filter/blur, поэтому «стеклянная» карточка — заранее посчитанная плоская # заливка вместо rgba(255,255,255,.5) + blur (см. фон карточки в ObjectSummary.tsx). _BRACKET = "#9bbce0" # tokens.bracket — акцент HUD corner-bracket _TRACK = "#dbe6f1" # tokens.track — «range track + donut track» (дословно из tokens.ts, # один токен на оба применения: подложка _price_range_chart_svg И трек-круг # _data_quality_gauge_svg — этот этап добавляет второе использование). # Legacy aliases — см. комментарий блока выше; убрать при редизайне тела отчёта. _FG = _INK _ACCENT_SOFT = _BADGE_TINT _ACCENT_2 = _WARN # у Mera нет отдельного тёплого "accent-2" — ближайший аналог _ACCENT_2_SOFT = "#f6f1e8" # мягкий тёплый тон (12% _WARN на белом) — иконки Offer-страницы _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом) — фон warning-плашек _BORDER = _LINE _BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline) # Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит # tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не # brand-данные (одинаковая для всех white-label брендов) — косметическая деталь # HUD, а не версия PDF-модуля/API. _REPORT_ENGINE_VERSION = "v2.0.6" # 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") # Manrope (UI-текст) и IBM Plex Mono (числа/лейблы) — та же пара шрифтов, что # зарегистрирована в живом /trade-in/v2 (см. frontend/src/app/v2/layout.tsx, # next/font/google, subsets latin+cyrillic). Variable-font Manrope[wght].ttf # инстанцирован в статические Regular/Bold через fonttools varLib.instancer # (варьируемые шрифты не поддерживаются как @font-face src в WeasyPrint) — # исходный variable-файл НЕ коммитится, только полученные статические веса. # IBM Plex Mono подключён только 400/500 (см. @font-face ниже) — bold mono # не понадобился нигде в текущей вёрстке. _MANROPE_REGULAR_URL = _font_data_url("Manrope-Regular.ttf") _MANROPE_BOLD_URL = _font_data_url("Manrope-Bold.ttf") _IBM_PLEX_MONO_REGULAR_URL = _font_data_url("IBMPlexMono-Regular.ttf") _IBM_PLEX_MONO_MEDIUM_URL = _font_data_url("IBMPlexMono-Medium.ttf") # Регистрируем файлы в matplotlib. С этого этапа chart endpoint-labels (см. # _price_range_chart_svg) рендерятся Manrope — той же гарнитурой, что и весь # HUD-хром (_page_header/_page_footer) и (следующий шаг) тело отчёта; PT Sans # по-прежнему зарегистрирован (нужен для @font-face тела документа, ещё не # переключённого на Manrope в этом этапе). _font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Regular.ttf")) _font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Bold.ttf")) _font_manager.fontManager.addfont(str(_FONTS_DIR / "Manrope-Regular.ttf")) _font_manager.fontManager.addfont(str(_FONTS_DIR / "Manrope-Bold.ttf")) matplotlib.rcParams["font.family"] = "Manrope" # ── 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: """Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома): акцентная точка цвета источника + название на светлой заливке. Раньше — сплошной непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра (Mera v2), бренд-цвет остаётся только акцентной точкой. Название — из _SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов) — overflow-wrap на всякий случай, если source незнаком и попадёт .title() произвольной длины (см. fallback ниже).""" dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0] 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}" # Город (region 66) → буквенный префикс № отчёта. Раньше _report_number # захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал # екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс # теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично # _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX. _CITY_REPORT_PREFIX: dict[str, str] = { "екатеринбург": "ЕКБ", "нижний тагил": "НТ", "каменск-уральский": "КУ", "первоуральск": "ПРВ", "серов": "СЕР", "новоуральск": "НВУ", "ревда": "РЕВ", "полевской": "ПЛВ", "асбест": "АСБ", "верхняя пышма": "ВП", "березовский": "БРЗ", "краснотурьинск": "КРТ", "камышлов": "КМШ", } # Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) — # НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty). _DEFAULT_REPORT_PREFIX = "МЕРА" # Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком. _REPORT_CITY_RE = re.compile( r"\b(?:" + "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True)) + r")\b" ) def _report_city_prefix(address: str | None) -> str: """Буквенный префикс № отчёта по городу из адреса (region 66). «МЕРА» если город не распознан — не выдумываем локацию (раньше был хардкод «EKБ» для любого адреса, включая не-екатеринбургские). ё→е нормализация до поиска (симметрично _resolve_target_city в estimator.py).""" if not address: return _DEFAULT_REPORT_PREFIX norm = address.replace("ё", "е").replace("Ё", "е").lower() m = _REPORT_CITY_RE.search(norm) return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX def _report_number(estimate: AggregatedEstimate) -> str: """№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'. PREFIX — буквенный код города оценки (ЕКБ / НТ / … по target_address, «МЕРА» если город не распознан); NNNN/XXXXXXXXXX — детерминированные коды от estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty).""" prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address) short = int(estimate.estimate_id.int) % 10_000 long = int(estimate.estimate_id.int) % 10_000_000_000 return f"{prefix}-{short:04d}-{long:010d}" def _expires_date(estimate: AggregatedEstimate) -> dt.date: """Срок действия данных отчёта — estimate.expires_at, fallback +30 дней. Та же логика, что в _build_cover; вынесена сюда, чтобы _page_footer могла показать «ДЕЙСТВИТЕЛЕН ДО» одинаково на всех страницах отчёта.""" if estimate.expires_at is not None: return estimate.expires_at.date() return dt.date.today() + dt.timedelta(days=30) 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/footer — HUD idiom, портировано из /trade-in/v2 # (HeroBar.tsx meta-cells + Footer.tsx wordmark composition). Раньше — плашка с # логотипом + текстовая полоска (cover) / голая footer-строка (pages 2-4); теперь # единый персистентный masthead + единый footer на ВСЕХ 4 страницах отчёта. ──── def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: ignore[no-untyped-def] """Masthead: слева — wordmark бренда (точка + tracked-uppercase имя, трактовка Footer.tsx, но цвет по-прежнему driven brand.primary_color — white-label не тронут), справа — компактные mono meta-cells (№ отчёта / дата), разделённые тонкими вертикальными hairline (см. meta-row HeroBar.tsx).""" logo_url = _safe_logo_url(brand.logo_url) if logo_url: # уже жёстко ограничен max-height/max-width — переполнение исключено # само по себе; brand.name виден только в alt (не рендерится как текст). mark_html = ( f"" f"{_html.escape(brand.name)}" ) else: # #overflow-hardening: brand.name — white-label произвольная строка, может # быть длиннее «Trade-In»/«Практика» — min-width:0 + overflow-wrap:anywhere # позволяет ей перенестись, а не наехать на meta-cell справа (flex-row). mark_html = ( f"" f"" f"{_html.escape(brand.name).upper()}" f"" ) def _meta_cell(label: str, value: str) -> str: # label/value — короткие гарантированно-ограниченные значения (лейбл ≤6 # символов, № отчёта/дата — фиксированный формат) — nowrap оправдан здесь. return ( f"" f"{_html.escape(label)}" f'" f"{_html.escape(value)}" ) meta_html = _meta_cell("ОТЧЁТ", report_num) + _meta_cell( "ДАТА", report_date.strftime("%d.%m.%Y") ) return ( f'
' f"{mark_html}" f'{meta_html}' f"
" ) def _page_footer( brand, # type: ignore[no-untyped-def] report_num: str, report_date: dt.date, page_note: str | None = None, *, valid_until: dt.date | None = None, ) -> str: """Единый footer — порт 3-частной композиции Footer.tsx (/trade-in/v2), но развёрнутый в 2 явных строки, а не 3 flex-item'а с fallback-переносом в одну (см. ниже #overflow-hardening про причину): строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта. page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер на офер-странице), которого нет в веб-референсе (там нет пагинации). Не удалён — вынесен приглушённой строкой НАД HUD-баром, чтобы не терять полезную для печатного многостраничного отчёта информацию. """ note_html = "" if page_note: note_html = ( f'
{page_note}
' ) valid_until_html = "" if valid_until is not None: valid_until_html = ( f'ДЕЙСТВИТЕЛЕН ДО ' f'{valid_until.strftime("%d.%m.%Y")}' ) mono_family = "'IBM Plex Mono','DejaVu Sans Mono',monospace" # #overflow-hardening: изначально meta / декоративная линия-коннектор / wordmark # были 3 flex-child'а ОДНОЙ строки с `flex-wrap:wrap` как fallback на случай # нехватки места. На практике meta (особенно со СРОК ДЕЙСТВИЯ на Cover) почти # всегда заполняет строку целиком, так что wordmark systematically уезжал на # свою отдельную строку — но даже НА ОТДЕЛЬНОЙ, целиком его строке, WeasyPrint # заметно занижал доступную ширину wordmark-item (воспроизводится детерминированно # и не зависело от max-width/margin декоративной линии или flex-shrink на самом # wordmark — похоже на ограничение реализации multi-line flex-wrap в WeasyPrint, # а не на конкуренцию за ширину), из-за чего даже короткий generic бренд # «Trade-In» рвался ПОСРЕДИ слова («TRADE-» / «IN») на КАЖДОЙ странице с footer'ом. # Фикс — не патчить flex-veights, а убрать саму двусмысленность: 2 явные # block-строки (meta / wordmark) вместо одной строки с fallback-переносом. # Декоративный коннектор (2 точки+градиент с каждой стороны) как самостоятельный # flex-item убран — он дублировал функцию border-top (уже отделяет footer от # тела страницы) и был источником сложности; заменён на простую тонкую # градиентную линию-разделитель между строками meta/wordmark. return f"""
{note_html}
ОТЧЁТ {_html.escape(report_num)} ДАТА {report_date.strftime("%d.%m.%Y")} {valid_until_html}
{_html.escape(brand.name).upper()} {_REPORT_ENGINE_VERSION}
""" # ── Price range chart — matplotlib SVG bullet/range chart (real chart, не div) ── # Раньше рендерилась одним div с фиксированной 64%-заливкой/14%-отступом — числа # НЕ определяли позицию заливки (чисто декоративный "прогресс-бар", не привязанный # к range_low/range_high). Теперь — настоящий график: серый трек на всю область # построения + акцентная заливка ровно на [range_low, range_high] (в млн ₽) + подписи- # концы (tabular figures, тот же PT Sans, что и весь документ) + опционально срок # экспозиции. Рендерится headless (Agg backend, см. import matplotlib.use выше) в # SVG и встраивается как base64 — 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=_TRACK, 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=_INK, 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'
{_html.escape(sub_label)}
' f'{_html.escape(sub_label)}' f"
" ) # ── Dual price headline: «Ориентир запроса» + «Ожидаемая цена продажи» ─────── # Зеркалит web HeroSummary (#648 S5a): две равновесные цифры. Asking — медиана # по объявлениям; expected_sold — реалистичная цена сделки (asking × ratio из # asking_to_sold_ratios, migration 080). Graceful: при отсутствии sold (None/0 — # старые оценки / пустой бакет ratio-таблицы) рисуем ТОЛЬКО asking-колонку. def _discount_pct(estimate: AggregatedEstimate) -> int | None: """Скидка запрос→продажа в % (как web): из asking_to_sold_ratio при наличии, иначе из самих чисел sold/median. None если sold нет или скидка < 1%.""" sold = estimate.expected_sold_price_rub if not sold or sold <= 0: return None ratio = estimate.asking_to_sold_ratio if ratio is not None and ratio > 0: pct = round((1 - ratio) * 100) elif estimate.median_price_rub > 0: pct = round((1 - sold / estimate.median_price_rub) * 100) else: return None return pct if pct >= 1 else None def _price_figure_cell( *, label: str, value_rub: int, range_low: int | None, range_high: int | None, per_m2: int | None, accent: str, delta_pct: int | None = None, ) -> str: """Одна ценовая колонка (label + крупное число + диапазон + ₽/м²). Каждое под-поле (диапазон / ₽/м²) рендерится только при валидном значении — как web (guard на typeof number). delta_pct — опциональный chip «−N%». Все числовые значения — DejaVu Sans Mono (tabular-nums), как на web (.mono). HUD-карточка (card-glass + bracket-tr, corner-bracket accent из tokens.ts) — вложенный
внутри (НЕ position:relative на самом — надёжность absolute-позиционированного ::before внутри table cell в разных движках рендеринга непредсказуема). padding-right на label-строке — clearance от delta_chip (короткий bounded chip, но всё равно на одной строке с label).""" delta_chip = "" if delta_pct is not None: delta_chip = ( f"" f"{_mono(f'−{delta_pct}%')}" ) range_html = "" if range_low is not None and range_high is not None and range_high > range_low: range_text = ( f"{_html.escape(_fmt_rub_m(range_low))} – {_html.escape(_fmt_rub_m(range_high))}" ) range_html = ( f"
{_mono(range_text)}
" ) perm2_html = "" if per_m2 is not None and per_m2 > 0: perm2_html = ( f"
{_mono(f'{_fmt_ppm2(per_m2)} ₽/м²')}
" ) return ( f"" f"
" f"
{_html.escape(label)}{delta_chip}
" f"
{_mono(_html.escape(_fmt_rub_m(value_rub)))}
" f"{range_html}{perm2_html}" f"
" ) def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def] """Две равновесные цены: «Ориентир запроса» и «Ожидаемая цена продажи». Graceful: если expected_sold_price_rub None/0 — одиночная колонка (asking), explainer/chip скрыты (текущее поведение PDF для старых оценок).""" accent = brand.primary_color if brand else "#1d4ed8" sold = estimate.expected_sold_price_rub has_sold = bool(sold) and sold > 0 asking_cell = _price_figure_cell( label="Ориентир запроса", value_rub=estimate.median_price_rub, range_low=estimate.range_low_rub, range_high=estimate.range_high_rub, per_m2=estimate.median_price_per_m2, accent=accent, ) if not has_sold: # Одиночная колонка — растягиваем на всю ширину (graceful fallback). single = asking_cell.replace("width:50%", "width:100%", 1) return ( f'{single}
' ) sold_cell = _price_figure_cell( label="Ожидаемая цена продажи", value_rub=sold, range_low=estimate.expected_sold_range_low_rub, range_high=estimate.expected_sold_range_high_rub, per_m2=estimate.expected_sold_per_m2, accent=_DANGER, delta_pct=_discount_pct(estimate), ) explainer = ( f'

' "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. " "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП " "Росреестра, обычно ниже запроса." "

" ) return ( f'{asking_cell}{sold_cell}
' f"{explainer}" ) # ── Shared HUD chrome for page bodies (Mera v2 — section-badge/status-pill/ # card-glass+bracket) — used by all 4 page-builders below. ────────────────── def _section_heading(number: str, text: str, *, tag: str = "h2", extra_right: str = "") -> str: """Заголовок страницы (h1 Cover / h2 Listings-Deals-Offer) с моно-нумерацией секции — HUD-идиома ObjectSummary.tsx («03 СВОДКА ОБЪЕКТА»), теперь общая для всех 4 страниц отчёта (раньше — только у cover не было border-bottom, у остальных был раздельный inline-стиль на каждой странице). extra_right — опциональный элемент справа (напр. status-pill); свой flex-item с flex-shrink:0, заголовок — flex:1 1 auto с min-width:0, чтобы длинный текст (в этом модуле текст заголовков статический, но структура остаётся safe by construction для любого будущего динамического текста) переносился, а не наезжал на правый элемент.""" size = _FS_XL if tag == "h1" else _FS_LG right_html = f'{extra_right}' if extra_right else "" return ( '
' '
' f'{number}' f"<{tag} style=\"font-family:'PT Serif','DejaVu Serif',serif;font-size:{size};" "font-weight:700;text-transform:uppercase;letter-spacing:0.02em;line-height:1.25;" f'margin:0;">{text}' "
" f"{right_html}" "
" ) def _freshness_status_pill(freshness_minutes: int | None) -> str: """Status-pill (.status-pill — ObjectSummary.tsx «АКТУАЛЬНО» идиома) — честная метка свежести данных из estimate.data_freshness_minutes (реальное поле, НЕ fabricated метрика). Пусто, если поле не заполнено (graceful — старые записи без этого поля/пустой рехайдрат) — по аналогии с остальными optional-полями оценки в этом модуле (см. _dual_price_block).""" if freshness_minutes is None: return "" hours = freshness_minutes / 60 if hours <= 24: label, color, border = "АКТУАЛЬНО", _SUCCESS, _SUCCESS_BORDER elif hours <= 24 * 7: label, color, border = "ОБНОВЛЕНО НА ЭТОЙ НЕДЕЛЕ", _WARN, "#e3d4b3" else: label, color, border = "ТРЕБУЕТ ОБНОВЛЕНИЯ", _DANGER, "#e8c9c9" # Три фиксированных RU-строки из закрытого набора (не free-form данные) — # nowrap оправдан тем же порядком, что и у web-референса (ObjectSummary.tsx # ставит whiteSpace:"nowrap" на идентичный pill). return ( f"{label}" ) # Слоты источников для «Качество данных» — портировано 1:1 из mappers.ts # SOURCE_SLOTS/TOTAL_SOURCES (см. buildSources/activeSourceCount): 5 живых # источников; N1/Restate намеренно исключены там же (#2081/#2204 — мёртвые). _QUALITY_SOURCE_SLOTS: tuple[tuple[str, ...], ...] = ( ("cian",), ("yandex",), ("rosreestr",), ("avito", "avito_imv"), ("domklik", "domclick"), ) _TOTAL_SOURCES = len(_QUALITY_SOURCE_SLOTS) # Web-референс (ObjectSummary.tsx, статичная demo-fixture): confidence="средняя" # → strokeDasharray=126/strokeDashoffset=72 → дуга заполнена ~43%. low/high — # пропорциональная экстраполяция. Это категориальное визуальное кодирование ТОГО # ЖЕ поля confidence, что уже показано текстом рядом (см. _conf_label) — НЕ # отдельная выдуманная точная метрика (в AggregatedEstimate нет отдельного # числового quality-score). _QUALITY_TIER_PCT: dict[str, float] = {"high": 0.78, "medium": 0.43, "low": 0.22} def _coeff_var_pct(values: list[float]) -> int | None: """Коэффициент вариации (%, округлён до целого) — портировано 1:1 из mappers.ts coeffVar/cvStr (population stddev / mean × 100). None при <2 валидных (>0) значениях — как на web рендерится «—».""" clean = [v for v in values if v and v > 0] if len(clean) < 2: return None mean = sum(clean) / len(clean) if mean == 0: return None variance = sum((v - mean) ** 2 for v in clean) / len(clean) return round((variance**0.5) / mean * 100) def _active_source_count(estimate: AggregatedEstimate) -> int: """Портировано 1:1 из mappers.ts activeSourceCount/buildSources — слот активен, если он в sources_used ИЛИ реально дал лоты (analogs+actual_deals).""" counts: dict[str, int] = {} for lot in (*estimate.analogs, *estimate.actual_deals): s = (lot.source or "").lower() if s: counts[s] = counts.get(s, 0) + 1 used = {s.lower() for s in (estimate.sources_used or [])} active = 0 for slot in _QUALITY_SOURCE_SLOTS: count = sum(counts.get(k, 0) for k in slot) if count > 0 or any(k in used for k in slot): active += 1 return active def _data_quality_gauge_svg(confidence: str, *, size: int = 46) -> str: """Кольцевой SVG-индикатор — техника ObjectSummary.tsx (2 концентрических , stroke-dasharray/stroke-dashoffset), инлайн в HTML (WeasyPrint поддерживает inline — см. существующие adv-ic иконки Offer-страницы), НЕ matplotlib и НЕ внешний файл.""" r = 20.0 circumference = 2 * 3.14159265358979 * r pct = _QUALITY_TIER_PCT.get(confidence, 0.5) dashoffset = circumference * (1 - pct) color = {"high": _SUCCESS, "medium": _WARN, "low": _DANGER}.get(confidence, _MUTED) return ( f'' f'' f'' f'' f'' f"" ) def _quality_card_html(estimate: AggregatedEstimate) -> str: """«КАЧЕСТВО ДАННЫХ» карточка (Cover) — донат-индикатор + 3 честные метрики (источников / достоверность / разброс цен), см. ObjectSummary.tsx. Все числа — реальные вычисленные значения (не fabricated), см. _active_source_count/ _coeff_var_pct/_conf_label. flex-wrap + min-width:0 + overflow-wrap:anywhere — метки короткие и bounded, но структура остаётся safe by construction.""" n_active = _active_source_count(estimate) cv = _coeff_var_pct([lot.price_per_m2 for lot in estimate.analogs]) cv_str = f"{cv}%" if cv is not None else "—" gauge = _data_quality_gauge_svg(estimate.confidence) return f"""
{gauge}
Качество данных
Источников {n_active} / {_TOTAL_SOURCES} Достоверность {_conf_label(estimate.confidence)} Разброс цен {cv_str}
""" # ── Page 1: Cover ──────────────────────────────────────────────────────────── def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] today = dt.date.today() # Используем реальное поле expires_at из оценки; fallback на +30 дней если None. expires = ( estimate.expires_at.date() if estimate.expires_at is not None else today + dt.timedelta(days=30) ) report_num = _report_number(estimate) # Короткий адрес (для cover): берём первую часть до запятой full_address = input_snapshot.get("address", "—") address_short = full_address.split(",")[0:3] address_short = ", ".join(s.strip() for s in address_short) address = _html.escape(address_short or full_address) area = input_snapshot.get("area_m2", 0) rooms = input_snapshot.get("rooms", 0) floor = input_snapshot.get("floor") or "—" total_floors = input_snapshot.get("total_floors") or "—" year_built = input_snapshot.get("year_built", "—") house_type = input_snapshot.get("house_type") repair_state = input_snapshot.get("repair_state") has_balcony = input_snapshot.get("has_balcony") house_labels = { "panel": "Панельный", "brick": "Кирпичный", "monolith": "Монолитный", "monolith_brick": "Монолит-кирпич", "other": "Другое", } repair_labels = { "needs_repair": "Требуется ремонт", "standard": "Стандартный", "good": "Хороший", "excellent": "Евроремонт", } rooms_label = ( "Студия" if rooms == 0 else f"{rooms} комнат" + ("а" if rooms == 1 else "ы" if 2 <= rooms <= 4 else "") ) house_label = house_labels.get(house_type, "—") if house_type else "—" repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано" balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—" # #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в # таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях. year_label = _mono(str(year_built)) if year_built else "—" # Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs; # если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty). days_range = _days_on_market_range(estimate.analogs) listings_bar = _price_range_chart_svg( estimate.range_low_rub, estimate.range_high_rub, sub_label="Активный рынок с аналогичным состоянием ремонта", days_min=days_range[0] if days_range else None, days_max=days_range[1] if days_range else None, show_days=days_range is not None, ) # Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ # рассчитанный дисконт запрос→продажа (тот же, что chip «−N%» в dual-price), # а не хардкод «10–18%», который противоречил бы вычисленному «−N%» (#pdf-honesty). discount_pct = _discount_pct(estimate) if discount_pct is not None: advice_discount_text = ( f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту " f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, " f"ДомКлик и продажи агентств недвижимости" ) else: advice_discount_text = ( "Фактические сделки проходят ниже цен в объявлениях, что подтверждают " "Росреестр, ДомКлик и продажи агентств недвижимости" ) disclaimer_html = "" if brand.pdf_disclaimer: disclaimer_html = ( f"

" f"{_html.escape(brand.pdf_disclaimer)}

" ) heading_html = _section_heading( "01", "АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ", tag="h1", extra_right=_freshness_status_pill(estimate.data_freshness_minutes), ) return f"""
{heading_html}
№ отчета {_mono(report_num)}
Дата отчета {_mono(today.strftime("%d.%m.%Y"))}
Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))}
Адрес{address}
Год постройки {year_label}
Тип дома{house_label}
Этаж / этажность {_mono(f"{floor} / {total_floors}")}
Площадь {_mono(f"{area} м²")}
Планировка {rooms_label}
Состояние ремонта {repair_label}
Балкон / лоджия{balcony_label}
{_quality_card_html(estimate)} {_dual_price_block(estimate, brand)}

Диапазон цен в объявлениях (без учета ремонта)

{listings_bar}

Что важно при оценке стоимости квартиры:

Цены в объявлениях — ожидания собственников {advice_discount_text}
Ремонт оценивается по состоянию, а не по вложенным суммам Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры

Этот отчёт онлайн: {settings.public_url}?id={estimate.estimate_id}

{disclaimer_html}
""" def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None: """Min/max days_on_market по аналогам, либо None если реальных данных нет. Раньше возвращал хардкод (4, 118) при отсутствии days_on_market — обложка и страница объявлений печатали этот выдуманный коридор как измеренный срок экспозиции (~48% оценок без days_on_market). Теперь None → caller не рисует срок (show_days=False), как уже делает страница сделок (#pdf-honesty).""" days = [lot.days_on_market for lot in lots if lot.days_on_market is not None] if not days: return None return min(days), max(days) def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int, int]: """Min/max price от deals. Fallback к listings range.""" if not deals: return fallback prices = [d.price_rub for d in deals] return min(prices), max(prices) # ── Page 2: Listings (market) ──────────────────────────────────────────────── def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] n_total = estimate.n_analogs # #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует # аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160), # а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного # count «с учётом ремонта» не существует, второе число было идентично n_total. # Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список). sources_to_show = estimate.sources_used or [] sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5]) # Params правой колонки — параметры поиска (НЕ конкретной квартиры) area = float(input_snapshot.get("area_m2", 0) or 0) area_min = round(area * 0.85, 1) area_max = round(area * 1.15, 1) rooms = input_snapshot.get("rooms", 0) year_built = input_snapshot.get("year_built") year_range = f"{year_built - 2}-{year_built + 2}" if year_built else "—" house_type = input_snapshot.get("house_type") house_label = ( { "panel": "Эконом до 2000 года", "brick": "Кирпич до 2000", "monolith": "Современный (монолит)", "monolith_brick": "Современный (м-к)", "other": "Любой", }.get(house_type, "Любой") if house_type else "Любой" ) repair_state = input_snapshot.get("repair_state") repair_label = ( { "needs_repair": "Требуется ремонт", "standard": "Стандартный", "good": "Хороший", "excellent": "Евроремонт", }.get(repair_state, "Любой") if repair_state else "Любой" ) rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты" # Полоска диапазона — срок экспозиции только при реальных days_on_market # (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty). days_range = _days_on_market_range(estimate.analogs) range_bar = _price_range_chart_svg( estimate.range_low_rub, estimate.range_high_rub, sub_label="Рынок", days_min=days_range[0] if days_range else None, days_max=days_range[1] if days_range else None, show_days=days_range is not None, ) # Топ-5 примеров (отсортированных по distance) top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5] examples_rows = _examples_rows(top5) heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ") return f"""
{heading_html}
Количество объявлений по аналогичным объектам {_mono(f"{n_total} шт.")}
Источники данных
{sources_html}
Расположение ± 1 км от локации дома
Тип дома {house_label}
Год постройки {_mono(year_range)}
Диапазон площади {_mono(f"{area_min} - {area_max} м²")}
Планировка {rooms_label}
Состояние ремонта{repair_label}

Диапазон цен в объявлениях

{range_bar}

Примеры аналогичных объектов с учётом ремонта

{_examples_table_head()} {examples_rows}
""" # colgroup общий для examples-table (Listings/Deals) — table-layout:fixed + # явные проценты вместо auto-layout, который на длинных адресах мог раздувать # таблицу шире полосы набора (переполнение страницы читается как «наложение»). _EXAMPLES_COLGROUP = ( '' '' ) def _examples_table_head() -> str: """+ общий для _build_listings_page/_build_deals_page — одна и та же структура таблицы примеров на обеих страницах.""" return f"""{_EXAMPLES_COLGROUP} Адрес квартиры Источник Стоимость 1 м², руб. Стоимость объекта, руб. Срок экспозиции, дней """ def _examples_rows(lots: list[AnalogLot]) -> str: if not lots: return ( "Нет данных" ) rows = [] for lot in lots: addr = _html.escape(lot.address) safe = _safe_url(lot.source_url) if safe: addr_cell = ( f"
" f"{addr} ↗" ) else: addr_cell = addr ppm2_cell = _fmt_ppm2(lot.price_per_m2) if lot.price_per_m2 is not None else "—" price_cell = _fmt_ppm2(lot.price_rub) if lot.price_rub is not None else "—" # #1533: 0 — валидное значение (лот размещён сегодня), `0 or '—'` теряло бы его. days_cell = lot.days_on_market if lot.days_on_market is not None else "—" # Адрес/источник — data-derived, БЕЗ white-space:nowrap, с overflow-wrap:anywhere # (длинный адрес/название ЖК без пробелов не должен раздвигать колонку — # см. _EXAMPLES_COLGROUP). ₽/м²/цена/срок — числовые/bounded, nowrap уместен. rows.append( "" f"{addr_cell}" f"{_source_badge_inline(lot.source)}" f"{_mono(str(ppm2_cell))}" f"{_mono(str(price_cell))}" f"{_mono(str(days_cell))}" "" ) return "".join(rows) # ── Page 3: Deals ──────────────────────────────────────────────────────────── def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] n_deals = len(estimate.actual_deals) today = dt.date.today() period_start = today - dt.timedelta(days=estimate.period_months * 30) # Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа # (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%», # который противоречил бы вычисленному «−N%» в том же PDF. Убран и ложный # хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty). discount_pct = _discount_pct(estimate) if discount_pct is not None: deals_discount_text = ( "По данным реальных сделок, квартиры продаются дешевле, чем заявлено в " f"объявлениях — по этому объекту на {discount_pct}%" ) else: deals_discount_text = ( "По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях" ) # Источники для сделок — берём из estimate.sources_used (не захардкоженный список). # Фильтруем по известным источникам сделок; fallback к пустому (не fabricate). _deal_source_keys = {"etazhi", "domklik", "rosreestr"} deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys] if not deal_sources: deal_sources = [s for s in (estimate.sources_used or [])] sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5]) area = float(input_snapshot.get("area_m2", 0) or 0) area_min = round(area * 0.85, 1) area_max = round(area * 1.15, 1) rooms = input_snapshot.get("rooms", 0) year_built = input_snapshot.get("year_built") year_range = f"{year_built - 2}–{year_built + 2}" if year_built else "—" house_type = input_snapshot.get("house_type") # #1532: маппинг идентичен странице «Листинги» (включая monolith_brick/other), # иначе один и тот же «Тип дома» рассинхронизирован между стр.2 и стр.3. house_label = ( { "panel": "Эконом до 2000 года", "brick": "Кирпич до 2000", "monolith": "Современный (монолит)", "monolith_brick": "Современный (м-к)", "other": "Любой", }.get(house_type, "Любой") if house_type else "Любой" ) rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты" deals_low, deals_high = _deals_range( estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub) ) # #1366: НЕ показываем срок экспозиции для бара сделок. actual_deals — это # зарегистрированные ДКП Росреестра без days_on_market (поле None), поэтому # _days_on_market_range вернул бы хардкод-fallback (4, 118) — выдуманные сроки # под заголовком «Диапазон цен по фактическим сделкам». Cover-страница так же # намеренно не рисует days для бара сделок (show_days по умолчанию False). range_bar = _price_range_chart_svg( deals_low, deals_high, sub_label="Рынок", ) top5 = estimate.actual_deals[:5] examples_rows = _examples_rows(top5) heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ") return f"""
{heading_html}
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Период сделок {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
Источники данных
{sources_html}
Расположение ± 1 км от локации дома
Тип дома {house_label}
Год постройки {_mono(year_range)}
Диапазон площади {_mono(f"{area_min} - {area_max} м²")}
Планировка{rooms_label}

Диапазон цен по фактическим сделкам

{range_bar}
{deals_discount_text}

Примеры аналогичных объектов

{_examples_table_head()} {examples_rows}
""" # ── Page 4: Offer (Trade-In vs Самопродажа) ────────────────────────────────── def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] median = estimate.median_price_rub # Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»), # часть — фиксированные диапазоны рынка ЕКБ (риелтор/аренда/юрист/реклама). listing_price = median # цена в объявлении torg_pct_low, torg_pct_high = 5, 15 torg_low = int(listing_price * torg_pct_low / 100) torg_high = int(listing_price * torg_pct_high / 100) # Data-consistency fix: sold_price теперь зеркалит cover (_dual_price_block) — берём # estimate.expected_sold_price_rub (та же реальная цифра по ДКП Росреестра, # что на cover в «Ожидаемая цена продажи»), а не независимый плоский 10% # хайркат от медианы. Раньше стр.1 и стр.4 одного и того же PDF могли # показывать разные цифры (расхождение 10%+) — тот же класс бага, что и # _discount_pct/_dual_price_block graceful fallback ниже. sold = estimate.expected_sold_price_rub if sold is not None and sold > 0: sold_price = sold else: # Fallback только для старых оценок без sold-данных (None/<=0). sold_price = listing_price - int(listing_price * 0.10) rieltor_pct_low, rieltor_pct_high = 2, 5 rieltor_low = int(sold_price * rieltor_pct_low / 100) rieltor_high = int(sold_price * rieltor_pct_high / 100) # 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45 # тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше # был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные). # Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026: # студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k. # Множители — грубое отношение верхних границ этих диапазонов к базовой (1к). rooms = int(input_snapshot.get("rooms", 0) or 0) if rooms <= 1: rent_multiplier = 1.0 elif rooms == 2: rent_multiplier = 1.35 elif rooms == 3: rent_multiplier = 1.8 else: rent_multiplier = 2.4 rent_base_low, rent_base_high = 84_000, 135_000 rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000 rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000 # Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ. juridical_low, juridical_high = 15_000, 50_000 # Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого # ЕКБ-агрегированного источника), см. sub-caption "ориентировочно" в таблице. ads_low, ads_high = 6_000, 45_000 total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low total_high = torg_high + rieltor_high + rent_high + juridical_high + ads_high brand_short = _html.escape(brand.name) trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In" return f"""
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}

Структура стоимости сделки

{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}%")}
Аренда после сделки
{f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 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)}")}
Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при самостоятельной продаже квартиры

Преимущества

Экономия времени
Берём на себя показы, переговоры и поиск покупателей
Юридическая безопасность
Проверяем документы, исключаем риски, сопровождаем на всех этапах
Фиксированная стоимость новостройки
Сохраняем цену выбранной планировки в {brand_short}
Гарантия цены
Снимаем угрозу колебаний рынка и удерживаем договорную стоимость

Расчёт носит ориентировочный характер и не является офертой.

""" # ── CSS ────────────────────────────────────────────────────────────────────── def _build_css(brand=None) -> str: # type: ignore[no-untyped-def] primary = brand.primary_color if brand else "#1d4ed8" return f""" @font-face {{ font-family: 'PT Sans'; src: url({_PT_SANS_REGULAR_URL}) format('truetype'); font-weight: 400; font-style: normal; }} @font-face {{ font-family: 'PT Sans'; src: url({_PT_SANS_BOLD_URL}) format('truetype'); font-weight: 700; font-style: normal; }} @font-face {{ font-family: 'PT Serif'; src: url({_PT_SERIF_BOLD_URL}) format('truetype'); font-weight: 700; font-style: normal; }} @font-face {{ font-family: 'Manrope'; src: url({_MANROPE_REGULAR_URL}) format('truetype'); font-weight: 400; font-style: normal; }} @font-face {{ font-family: 'Manrope'; src: url({_MANROPE_BOLD_URL}) format('truetype'); font-weight: 700; font-style: normal; }} @font-face {{ font-family: 'IBM Plex Mono'; src: url({_IBM_PLEX_MONO_REGULAR_URL}) format('truetype'); font-weight: 400; font-style: normal; }} @font-face {{ font-family: 'IBM Plex Mono'; src: url({_IBM_PLEX_MONO_MEDIUM_URL}) format('truetype'); font-weight: 500; font-style: normal; }} @page {{ size: A4; margin: 25mm 18mm 19mm 18mm; @top-center {{ content: element(runningHeader); vertical-align: bottom; }} @bottom-center {{ content: element(runningFooter); vertical-align: top; }} }} .running-header {{ position: running(runningHeader); }} .running-footer {{ position: running(runningFooter); }} * {{ box-sizing: border-box; }} body {{ font-family: 'PT Sans', 'DejaVu Sans', sans-serif; font-size: {_FS_MD}; color: {_INK}; background: {_PAGE_BG}; margin: 0; padding: 0; line-height: 1.35; }} h1, h2, h3 {{ margin: 0; }} h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }} .page {{ page-break-after: always; position: relative; }} .page:last-child {{ page-break-after: avoid; }} .bold {{ font-weight: 700; }} .mono, .mono * {{ font-family: 'DejaVu Sans Mono', monospace; font-variant-numeric: tabular-nums; }} /* Params table (Cover) — border-bottom пришёл из .dotted-row (см. HUD-блок ниже), применяется точечно per- в разметке (последняя строка — без разделителя, ObjectSummary.tsx идиома). overflow-wrap — значения ячейки (адрес и т.п.) data-derived, НЕ nowrap. */ .params-table td {{ padding: 2pt 4pt; font-size: {_FS_SM}; overflow-wrap: anywhere; }} .params-table td:first-child {{ color: {_MUTED}; width: 40%; }} .params-table td:last-child {{ text-align: right; }} /* Search params (Listings/Deals) */ .search-params td {{ padding: 4pt 0; font-size: {_FS_SM}; overflow-wrap: anywhere; }} .search-params td:first-child {{ color: {_MUTED}; }} .search-params td:last-child {{ text-align: right; }} /* Что важно — advice table */ .advice-table td {{ padding: 3.5pt 6pt; border-bottom: 1px solid {_BORDER}; vertical-align: top; font-size: {_FS_SM}; line-height: 1.3; }} .advice-title {{ font-weight: 700; width: 30%; color: {_FG}; }} .advice-text {{ color: {_MUTED}; }} /* Examples table (Listings/Deals) — не разрывать между страницами: длинный адрес без break-inside:avoid оставляет badge/цену на одной странице, а адрес на следующей (см. verify-фикс, #1531/#1532 таблица). */ .examples-table tr {{ break-inside: avoid; page-break-inside: avoid; }} /* Advantages (Offer) — тёплый accent-2 вместо generic red-tinted кругов */ .advantage-item {{ border: 1px solid {_BORDER}; border-radius: 4pt; padding: 8pt; text-align: left; break-inside: avoid; page-break-inside: avoid; }} .advantage-item .adv-ic {{ display: block; width: 28pt; height: 28pt; border-radius: 50%; background: {_ACCENT_2_SOFT}; margin: 0 0 6pt 0; line-height: 28pt; text-align: center; }} .advantage-item .adv-ic svg {{ vertical-align: middle; }} .advantage-item .adv-title {{ font-weight: 700; font-size: {_FS_XS}; margin-bottom: 3pt; color: {primary}; letter-spacing: -0.01em; }} .advantage-item .adv-desc {{ font-size: {_FS_XS}; color: {_MUTED}; line-height: 1.3; overflow-wrap: anywhere; }} /* ── Mera v2 HUD chrome (tokens.ts / ObjectSummary.tsx / HeroBar.tsx) ───────── Wired into all 4 page-builders this stage (_build_cover/_build_listings_page/ _build_deals_page/_build_offer_page + shared _section_heading/_quality_card_html/ _source_logo_pill/_price_figure_cell) — card-glass+bracket-tr on price/summary cards, section-badge on every page heading, status-pill (freshness, Cover), source-pill (Listings/Deals), dotted-row (params-table/search-params rows), bullet-dot (source list label). */ /* Glass card (flattened — no backdrop-filter/blur, see _CARD_BG comment). */ .card-glass {{ position: relative; background: {_CARD_BG}; border: 1pt solid {_LINE_2}; border-radius: 6pt; }} /* Corner-bracket accent — small absolutely-positioned marks, NOT clip-path (WeasyPrint clip-path support is unreliable). Apply to a `position:relative` ancestor; one or more corners per card, see HeroBar.tsx (4 corners) vs ObjectSummary.tsx (single top-right corner). */ .bracket-tl::before, .bracket-tr::before, .bracket-bl::before, .bracket-br::before {{ content: ""; position: absolute; width: 11pt; height: 11pt; }} .bracket-tl::before {{ top: -1pt; left: -1pt; border-top: 1.5pt solid {_BRACKET}; border-left: 1.5pt solid {_BRACKET}; }} .bracket-tr::before {{ top: -1pt; right: -1pt; border-top: 1.5pt solid {_BRACKET}; border-right: 1.5pt solid {_BRACKET}; }} .bracket-bl::before {{ bottom: -1pt; left: -1pt; border-bottom: 1.5pt solid {_BRACKET}; border-left: 1.5pt solid {_BRACKET}; }} .bracket-br::before {{ bottom: -1pt; right: -1pt; border-bottom: 1.5pt solid {_BRACKET}; border-right: 1.5pt solid {_BRACKET}; }} /* Numbered mono section badge — "03" next to a tracked-uppercase heading (ObjectSummary.tsx). */ .section-badge {{ font-family: 'IBM Plex Mono', 'DejaVu Sans Mono', monospace; font-size: {_FS_MD}; color: {_ACCENT}; }} /* Status pill — dot + uppercase text, soft border, fully rounded (ObjectSummary.tsx "АКТУАЛЬНО"). Default tint is success; override color/ border-color inline for warn/danger variants. */ .status-pill {{ display: inline-flex; align-items: center; gap: 4pt; font-size: {_FS_XS}; letter-spacing: 0.05em; text-transform: uppercase; color: {_SUCCESS}; border: 1pt solid {_SUCCESS_BORDER}; border-radius: 20pt; padding: 2pt 7pt; }} .status-pill::before {{ content: ""; display: inline-block; width: 5pt; height: 5pt; border-radius: 50%; background: currentColor; }} /* Source pill — soft-tinted geometry, replacing today's solid-colour chips (_source_logo_pill/_source_badge_inline still emit the old solid style this stage — the per-source soft background/text colour is computed there, not here; this class only supplies the shared shape/typography). */ .source-pill {{ display: inline-block; padding: 2pt 8pt; border-radius: 10pt; font-size: {_FS_XS}; font-weight: 600; border: 1pt solid {_LINE_2}; }} /* Dotted row separator (ObjectSummary.tsx summary rows). */ .dotted-row {{ border-bottom: 1pt dotted {_LINE_DOTTED}; }} /* Small colored bullet dot before row labels (ObjectSummary.tsx). */ .bullet-dot {{ display: inline-block; width: 5pt; height: 5pt; border-radius: 50%; background: {_ACCENT}; vertical-align: middle; }} """ # ── Public API ─────────────────────────────────────────────────────────────── def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] """Страница-заглушка для случаев, когда данных недостаточно (#697/#9). Рендерится вместо числового отчёта при estimate.insufficient_data=True. Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь. """ address = _html.escape(input_snapshot.get("address", "—")) report_num = _report_number(estimate) today = dt.date.today() return f"""
{_page_header(brand, report_num, today)}

№ {_mono(report_num)} · {_mono(today.strftime("%d.%m.%Y"))} · {address}

Недостаточно данных для оценки

По данному объекту не найдено достаточного количества аналогов для формирования надёжной рыночной оценки. Пожалуйста, уточните параметры или обратитесь к специалисту.

""" def generate_trade_in_pdf( estimate: AggregatedEstimate, input_snapshot: dict, # type: ignore[type-arg] *, brand=None, # type: ignore[no-untyped-def] ) -> bytes: """Генерирует WeasyPrint PDF отчёт Trade-In (собственная визуальная система). Если estimate.insufficient_data=True — рендерит одностраничный empty-state вместо числового отчёта («Недостаточно данных», #697/#9). Pages (normal): 1. Cover — № отчёта + параметры + 2 диапазона + 3 блока «Что важно» 2. Listings — рынок аналогов + источники + таблица примеров 3. Deals — фактические сделки + источники + таблица 4. Offer — Trade-In vs самопродажа + 4 преимущества Args: estimate: AggregatedEstimate из БД input_snapshot: ввод пользователя (address, area_m2, ...) brand: optional Brand для white-label Returns: PDF bytes для Response(media_type="application/pdf") """ # WeasyPrint импортируется лениво: его native-зависимости (pango/cairo) нужны # только для финального write_pdf — HTML-билдеры (cover/listings/...) работают # без них, что делает рендер-функции тестируемыми в окружениях без native libs. # FontConfiguration берём через атрибут (не отдельный `from weasyprint.text.fonts # import ...`) — тесты подменяют sys.modules['weasyprint'] плоским MagicMock без # реальных субмодулей, а точечный submodule-импорт на моке падает с # "'weasyprint' is not a package"; атрибутный доступ работает и на Mock, и на # реальном пакете (weasyprint/__init__.py уже импортирует .text.fonts внутри себя). import weasyprint from weasyprint import CSS, HTML from app.services.brand import Brand if brand is None: brand = Brand( slug="generic", name="Trade-In", logo_url=None, primary_color="#1d4ed8", accent_color="#f59e0b", footer_text=None, pdf_disclaimer=None, ) # Sanitize brand colors before inserting into CSS/style attributes (#13). brand = Brand( slug=brand.slug, name=brand.name, logo_url=brand.logo_url, primary_color=_safe_color(brand.primary_color, "#1d4ed8"), accent_color=_safe_color(brand.accent_color, "#f59e0b"), footer_text=brand.footer_text, pdf_disclaimer=brand.pdf_disclaimer, ) # #697/#9: insufficient data → empty-state page, NOT fabricated 0-median report. if estimate.insufficient_data: body_html = _build_insufficient_data_page(estimate, input_snapshot, brand) else: # Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page # margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице # документа, а не только там, где явно вставлен в поток секции-билдера. # Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint # печатает независимый running-элемент с последнего "видимого" перед разрывом # страницы, что дало бы 4 разных чанка вместо одного персистентного хрома. _chrome_report_num = _report_number(estimate) _chrome_today = dt.date.today() _chrome_expires = _expires_date(estimate) chrome_html = ( f'
' f"{_page_header(brand, _chrome_report_num, _chrome_today)}
" f'" ) body_html = ( chrome_html + _build_cover(estimate, input_snapshot, brand) + _build_listings_page(estimate, input_snapshot, brand) + _build_deals_page(estimate, input_snapshot, brand) + _build_offer_page(estimate, input_snapshot, brand) ) html_str = ( '' f"Trade-In — {_html.escape(input_snapshot.get('address', ''))}" f"{body_html}" ) css_str = _build_css(brand=brand) # FontConfiguration обязателен для того, чтобы @font-face (PT Sans/PT Serif, см. # _build_css) реально зарегистрировались и использовались при layout — без него # WeasyPrint молча игнорирует @font-face и подставляет fallback из font-stack # (DejaVu). Один и тот же объект передаётся в CSS(...) и write_pdf(...). font_config = weasyprint.text.fonts.FontConfiguration() # SSRF guard (#13): кастомный url_fetcher блокирует file:// и нелоговые схемы. # base_url=None — не resolve никакие относительные URL (все ресурсы data: или https:). # url_fetcher передаётся в HTML(...) и CSS(...) конструкторы, а НЕ в write_pdf() — # в WeasyPrint 69 write_pdf(url_fetcher=...) молча игнорируется # ("Unknown rendering option"), а @font-face-ресолвинг внутри CSS(...) происходит # синхронно при конструировании, до write_pdf(). Один и тот же fetcher — на оба # объекта, иначе guard не покрывает CSS-ресурсы (сейчас только data:-шрифты, но # это тот путь, по которому в будущем может прилететь brand-specific URL). url_fetcher = _make_safe_url_fetcher() pdf_bytes = HTML(string=html_str, base_url=None, url_fetcher=url_fetcher).write_pdf( stylesheets=[CSS(string=css_str, font_config=font_config, url_fetcher=url_fetcher)], font_config=font_config, ) logger.info( "PDF generated estimate_id=%s brand=%s size=%d insufficient_data=%s", estimate.estimate_id, brand.slug, len(pdf_bytes), estimate.insufficient_data, ) return pdf_bytes