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"{_html.escape(brand.name)}" - if logo_url - else f"{_html.escape(brand.name).upper()}" + 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"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}' + "
" + 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 "