diff --git a/tradein-mvp/backend/app/services/exporters/fonts/IBMPlexMono-Medium.ttf b/tradein-mvp/backend/app/services/exporters/fonts/IBMPlexMono-Medium.ttf
new file mode 100644
index 00000000..33c546f6
Binary files /dev/null and b/tradein-mvp/backend/app/services/exporters/fonts/IBMPlexMono-Medium.ttf differ
diff --git a/tradein-mvp/backend/app/services/exporters/fonts/IBMPlexMono-Regular.ttf b/tradein-mvp/backend/app/services/exporters/fonts/IBMPlexMono-Regular.ttf
new file mode 100644
index 00000000..0c9770d5
Binary files /dev/null and b/tradein-mvp/backend/app/services/exporters/fonts/IBMPlexMono-Regular.ttf differ
diff --git a/tradein-mvp/backend/app/services/exporters/fonts/Manrope-Bold.ttf b/tradein-mvp/backend/app/services/exporters/fonts/Manrope-Bold.ttf
new file mode 100644
index 00000000..dfd95290
Binary files /dev/null and b/tradein-mvp/backend/app/services/exporters/fonts/Manrope-Bold.ttf differ
diff --git a/tradein-mvp/backend/app/services/exporters/fonts/Manrope-Regular.ttf b/tradein-mvp/backend/app/services/exporters/fonts/Manrope-Regular.ttf
new file mode 100644
index 00000000..39c228c6
Binary files /dev/null and b/tradein-mvp/backend/app/services/exporters/fonts/Manrope-Regular.ttf differ
diff --git a/tradein-mvp/backend/app/services/exporters/fonts/OFL_IBMPlexMono.txt b/tradein-mvp/backend/app/services/exporters/fonts/OFL_IBMPlexMono.txt
new file mode 100644
index 00000000..670c6c04
--- /dev/null
+++ b/tradein-mvp/backend/app/services/exporters/fonts/OFL_IBMPlexMono.txt
@@ -0,0 +1,93 @@
+Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/tradein-mvp/backend/app/services/exporters/fonts/OFL_Manrope.txt b/tradein-mvp/backend/app/services/exporters/fonts/OFL_Manrope.txt
new file mode 100644
index 00000000..b13cba53
--- /dev/null
+++ b/tradein-mvp/backend/app/services/exporters/fonts/OFL_Manrope.txt
@@ -0,0 +1,93 @@
+Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index cb5db27a..b0428d84 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -1,13 +1,21 @@
"""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) — реальный
+Дизайн-токены (цвет / 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 копия конкурентного референса.
@@ -172,24 +180,61 @@ def _make_safe_url_fetcher(): # type: ignore[no-untyped-def]
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)
+# ── 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 страницах.
@@ -220,11 +265,29 @@ _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 цифры).
+# 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"))
-matplotlib.rcParams["font.family"] = "PT Sans"
+_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 с брендовыми цветами источников) ─
@@ -253,13 +316,21 @@ _SOURCE_DISPLAY_NAMES: dict[str, str] = {
def _source_logo_pill(source: str) -> str:
- """Pill-badge с цветом источника."""
- bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
+ """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)} "
+ ""
+ f" {_html.escape(name)} "
)
@@ -312,6 +383,16 @@ def _report_number(estimate_id: UUID) -> str:
return f"EKБ-{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")
@@ -324,35 +405,149 @@ def _conf_label(confidence: str) -> str:
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
-# ── Unified page header (cover + pages 2-4 использовали визуально разные
-# паттерны — сплошная цветная плашка vs текстовая полоска; теперь один блок) ──
+# ── 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) -> str: # type: ignore[no-untyped-def]
- """Логотип/имя бренда + акцентная линия снизу — единый паттерн для всех 4 страниц.
-
- brand.primary_color по-прежнему управляет цветом линии/плашки (white-label
- механизм не тронут) — только markup унифицирован.
- """
+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)
- mark_html = (
- f" "
- if logo_url
- else f"{_html.escape(brand.name).upper()} "
+ if logo_url:
+ # уже жёстко ограничен max-height/max-width — переполнение исключено
+ # само по себе; brand.name виден только в alt (не рендерится как текст).
+ mark_html = (
+ f""
+ f" "
+ )
+ 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"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
+ f'padding-bottom:6pt;margin-bottom:9pt;">'
f"{mark_html}"
- f'Trade-In отчёт '
+ 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%-отступом — числа
# НЕ определяли позицию заливки (чисто декоративный "прогресс-бар", не привязанный
@@ -421,7 +616,7 @@ def _price_range_chart_svg(
(lo_m - pad, track_bottom),
(hi_m - lo_m) + 2 * pad,
track_h,
- facecolor=_BORDER,
+ facecolor=_TRACK,
edgecolor="none",
zorder=1,
)
@@ -457,7 +652,7 @@ def _price_range_chart_svg(
xycoords="data",
xytext=(0, -4),
textcoords="offset points",
- color=_FG,
+ color=_INK,
fontsize=10.5,
ha=ha,
va="top",
@@ -536,13 +731,20 @@ def _price_figure_cell(
Каждое под-поле (диапазон / ₽/м²) рендерится только при валидном значении —
как web (guard на typeof number). delta_pct — опциональный chip «−N%».
- Все числовые значения — DejaVu Sans Mono (tabular-nums), как на web (.mono)."""
+ Все числовые значения — 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"{_mono(f'−{delta_pct}%')} "
+ f"border-radius:8pt;vertical-align:middle;white-space:nowrap;'>"
+ f"{_mono(f'−{delta_pct}%')}"
)
range_html = ""
if range_low is not None and range_high is not None and range_high > range_low:
@@ -550,24 +752,25 @@ def _price_figure_cell(
f"{_html.escape(_fmt_rub_m(range_low))} – {_html.escape(_fmt_rub_m(range_high))}"
)
range_html = (
- f""
- f"{_mono(range_text)}
"
+ f"{_mono(range_text)}
"
)
perm2_html = ""
if per_m2 is not None and per_m2 > 0:
perm2_html = (
- f""
- f"{_mono(f'{_fmt_ppm2(per_m2)} ₽/м²')}
"
+ f"{_mono(f'{_fmt_ppm2(per_m2)} ₽/м²')}
"
)
return (
- f" "
+ f" "
+ f""
f"
{_html.escape(label)}{delta_chip}
"
- f"
"
- f"{_mono(_html.escape(_fmt_rub_m(value_rub)))}
"
+ f"letter-spacing:0.03em;padding-right:16pt;'>{_html.escape(label)}{delta_chip}
"
+ f"{_mono(_html.escape(_fmt_rub_m(value_rub)))}
"
f"{range_html}{perm2_html}"
- f" "
+ f"
"
)
@@ -623,6 +826,169 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
)
+# ── 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}{tag}>'
+ "
"
+ 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 ────────────────────────────────────────────────────────────
@@ -701,49 +1067,68 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
if brand.pdf_disclaimer:
disclaimer_html = (
f""
+ f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
f"{_html.escape(brand.pdf_disclaimer)}
"
)
+ footer_html = _page_footer(brand, report_num, today, valid_until=expires)
+
+ heading_html = _section_heading(
+ "01",
+ "АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
+ tag="h1",
+ extra_right=_freshness_status_pill(estimate.data_freshness_minutes),
+ )
return f"""
- {_page_header(brand)}
+ {_page_header(brand, report_num, today)}
-
- АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ
-
+ {heading_html}
-
+
- № отчета {_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}
+ № отчета
+ {_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}
+
+ {_quality_card_html(estimate)}
+
{_dual_price_block(estimate, brand)}
-
- Диапазон цен в объявлениях
- (без учета ремонта)
-
- {listings_bar}
+
+
+ Диапазон цен в объявлениях
+ (без учета ремонта)
+
+ {listings_bar}
+
-
- Диапазон цен по фактическим сделкам
-
- {deals_bar}
+
+
+ Диапазон цен по фактическим сделкам
+
+ {deals_bar}
+
@@ -767,11 +1152,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
-
+
Этот отчёт онлайн: {settings.public_url}?id={estimate.estimate_id}
{disclaimer_html}
+ {footer_html}
"""
@@ -854,18 +1240,26 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
examples_rows = _examples_rows(top5)
+ report_num = _report_number(estimate.estimate_id)
+ today = dt.date.today()
+ footer_html = _page_footer(
+ brand,
+ report_num,
+ today,
+ f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2",
+ valid_until=_expires_date(estimate),
+ )
+
+ heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
+
return f"""
- {_page_header(brand)}
+ {_page_header(brand, report_num, today)}
-
- РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ
-
+ {heading_html}
-
+
@@ -873,20 +1267,27 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
Количество объявлений по аналогичным объектам
{_mono(f"{n_total} шт.")}
-
Источники данных
-
{sources_html}
+
+ Источники данных
+
{sources_html}
-
-
- Расположение ± 1 км от локации дома
- Тип дома {house_label}
- Год постройки {_mono(year_range)}
- Диапазон площади
- {_mono(f"{area_min} - {area_max} м²")}
- Планировка {rooms_label}
- Состояние ремонта {repair_label}
-
+
+
+
+ Расположение
+ ± 1 км от локации дома
+ Тип дома
+ {house_label}
+ Год постройки
+ {_mono(year_range)}
+ Диапазон площади
+ {_mono(f"{area_min} - {area_max} м²")}
+ Планировка
+ {rooms_label}
+ Состояние ремонта {repair_label}
+
+
@@ -899,7 +1300,31 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
Примеры аналогичных объектов с учётом ремонта
-
+
+ {_examples_table_head()}
+ {examples_rows}
+
+
+ {footer_html}
+
+
+"""
+
+
+# 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}
Срок экспозиции, дней
-
- {examples_rows}
-
-
-
-
-
-"""
+ """
def _examples_rows(lots: list[AnalogLot]) -> str:
@@ -939,7 +1354,8 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
if safe:
addr_cell = (
f"{addr} ↗ "
+ f"style='color:{_ACCENT};text-decoration:none;overflow-wrap:anywhere;'>"
+ f"{addr} ↗"
)
else:
addr_cell = addr
@@ -947,17 +1363,21 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
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""
- f"{_source_badge_inline(lot.source)} "
- f""
- f"{_mono(str(ppm2_cell))} "
- f""
- f"{_mono(str(price_cell))} "
- f""
- f"{_mono(str(days_cell))} "
+ 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)
@@ -1018,16 +1438,23 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
top5 = estimate.actual_deals[:5]
examples_rows = _examples_rows(top5)
+ report_num = _report_number(estimate.estimate_id)
+ footer_html = _page_footer(
+ brand,
+ report_num,
+ today,
+ f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3",
+ valid_until=_expires_date(estimate),
+ )
+
+ heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
+
return f"""
- {_page_header(brand)}
+ {_page_header(brand, report_num, today)}
-
- ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ
-
+ {heading_html}
@@ -1039,19 +1466,25 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
{_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
-
Источники данных
-
{sources_html}
+
+ Источники данных
+
{sources_html}
-
-
- Расположение ± 1 км от локации дома
- Тип дома {house_label}
- Год постройки {_mono(year_range)}
- Диапазон площади
- {_mono(f"{area_min} - {area_max} м²")}
- Планировка {rooms_label}
-
+
+
+
+ Расположение
+ ± 1 км от локации дома
+ Тип дома
+ {house_label}
+ Год постройки
+ {_mono(year_range)}
+ Диапазон площади
+ {_mono(f"{area_min} - {area_max} м²")}
+ Планировка {rooms_label}
+
+
@@ -1069,28 +1502,13 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
Примеры аналогичных объектов
-
-
-
- Адрес квартиры
- Источник
- Стоимость 1 м², руб.
- Стоимость объекта, руб.
- Срок экспозиции, дней
-
-
+
+ {_examples_table_head()}
{examples_rows}
-
+ {footer_html}
"""
@@ -1140,16 +1558,23 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
brand_short = _html.escape(brand.name)
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
+ report_num = _report_number(estimate.estimate_id)
+ today = dt.date.today()
+ footer_html = _page_footer(
+ brand,
+ report_num,
+ today,
+ f"{brand_short} · Анализ рынка вторичной недвижимости · стр. 4 · "
+ "Расчёт носит ориентировочный характер и не является офертой.",
+ valid_until=_expires_date(estimate),
+ )
+
return f"""
- {_page_header(brand)}
+ {_page_header(brand, report_num, today)}
-
- ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ
-
+ {_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
Структура стоимости сделки
@@ -1161,7 +1586,7 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
+ font-weight:700;overflow-wrap:anywhere;">
{trade_in_label}
Преимущества
+
@@ -1301,11 +1738,7 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
-
+ {footer_html}
"""
@@ -1335,6 +1768,30 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
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: 20mm 18mm 20mm 18mm;
@@ -1343,8 +1800,8 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
body {{
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
font-size: {_FS_MD};
- color: {_FG};
- background: {_BG};
+ color: {_INK};
+ background: {_PAGE_BG};
margin: 0;
padding: 0;
line-height: 1.35;
@@ -1360,11 +1817,14 @@ h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }}
font-variant-numeric: tabular-nums;
}}
-/* Params table (Cover) */
+/* Params table (Cover) — border-bottom пришёл из .dotted-row (см. HUD-блок ниже),
+ применяется точечно per- в разметке (последняя строка — без разделителя,
+ ObjectSummary.tsx идиома). overflow-wrap — значения ячейки (адрес и т.п.)
+ data-derived, НЕ nowrap. */
.params-table td {{
padding: 2pt 4pt;
- border-bottom: 1px solid {_BORDER};
font-size: {_FS_SM};
+ overflow-wrap: anywhere;
}}
.params-table td:first-child {{
color: {_MUTED};
@@ -1378,6 +1838,7 @@ h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }}
.search-params td {{
padding: 4pt 0;
font-size: {_FS_SM};
+ overflow-wrap: anywhere;
}}
.search-params td:first-child {{
color: {_MUTED};
@@ -1403,12 +1864,22 @@ h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }}
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;
@@ -1434,6 +1905,102 @@ h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }}
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;
}}
"""
@@ -1452,7 +2019,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
today = dt.date.today()
return f"""
- {_page_header(brand)}
+ {_page_header(brand, report_num, today)}
№ {_mono(report_num)} · {_mono(today.strftime("%d.%m.%Y"))} · {address}
diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py
index aa3aba52..0f04bfe7 100644
--- a/tradein-mvp/backend/tests/test_pdf_security.py
+++ b/tradein-mvp/backend/tests/test_pdf_security.py
@@ -210,7 +210,7 @@ def test_cover_falls_back_to_name_when_logo_none() -> None:
# Logo-specific check via _page_header directly — не проверяем отсутствие
во
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
# matplotlib SVG
(см. _price_range_chart_svg), не связанные с логотипом бренда.
- header_html = mod._page_header(_GENERIC)
+ header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
assert "