fix(tradein/pdf): честный embedded-шрифт (PT Sans/PT Serif) + matplotlib-график вместо div #2427

Merged
lekss361 merged 1 commit from fix/tradein-pdf-real-fonts-chart into main 2026-07-04 14:39:38 +00:00
Owner

Summary

Follow-up на PR #2327 (визуальный редизайн PDF-отчёта). Пользователь справедливо указал, что дизайн всё ещё выглядит не «премиум» — исследование 4 альтернативных Python PDF-стеков (ReportLab/Typst/Playwright/улучшенный WeasyPrint, см. предыдущий research) нашло точную причину: это execution gap в прошлом PR, не потолок возможностей WeasyPrint.

  • Честный шрифт: CSS объявлял font-family: 'DejaVu Serif'/'DejaVu Sans', но ни одного @font-face не было — документ всегда рендерился на системном fallback. Теперь PT Sans (400/700) + PT Serif (700, только заголовки секций) embedded как base64 @font-face из локальных .ttf (OFL-лицензия ParaType, в app/services/exporters/fonts/).
  • Реальный график: _price_range_bar был буквально <div> с фиксированной 64%/14% заливкой, никак не связанной с реальными числами. Заменён на _price_range_chart_svg — matplotlib bullet-chart на настоящем [range_low, range_high], embedded как SVG data URI.
  • Найден и исправлен баг: WeasyPrint требует явный FontConfiguration(), переданный и в CSS(), и в write_pdf() — без него @font-face молча игнорируется, рендер тихо откатывается на DejaVu. Без этого фикса шрифт бы не заработал вообще.
  • Найден и исправлен смежный security-баг (обнаружен при верификации, не связан напрямую с шрифтами): url_fetcher передавался только в write_pdf(), а в WeasyPrint 69 это молча игнорируется (Unknown rendering option) — SSRF-guard фактически не применялся к CSS-ресурсам (@font-face src:). Теперь url_fetcher передаётся в оба конструктора, HTML() и CSS().
  • Thread-safety: график переведён с глобального pyplot (plt.subplots/plt.close) на объектный Figure() API — write_pdf() вызывается в FastAPI threadpool, конкурентные PDF-запросы не должны делить глобальный figure-manager.

Test plan

  • pytest tests/test_pdf_security.py tests/services/test_trade_in_pdf_dual_price.py — 42/42 passed (локально и внутри Docker с реальным WeasyPrint/matplotlib)
  • ruff check + ruff format — чисто
  • Полный backend test suite (2265 passed, 1 pre-existing unrelated flake, подтверждено идентичным на чистом main)
  • Реальный Docker-рендер (prod-идентичный образ) — pdffonts подтвердил PT-Sans/PT-Sans-Bold/PT-Serif-Bold embedded (не silent fallback); графики визуально проверены на всех страницах, где используются (cover ×2, listings, deals)
  • Код-инспекция WeasyPrint 69 internals — подтверждено, что url_fetcher на CSS() реально доходит до @font-face resolution (preprocess_stylesheetfont_config.add_font_face)
  • code-reviewer pass — ⚠️ MINOR (2 пункта, оба исправлены в этом же PR: url_fetcher-wiring + pyplot thread-safety)

Известные ограничения (не блокеры, follow-up)

  • В worst-case сценарии (полный disclaimer + все 3 advice-строки + оба заполненных диапазона) cover-страница переполняется на дополнительную почти-пустую физическую страницу (5 страниц вместо 4) — косметика, не потеря контента.
## Summary Follow-up на PR #2327 (визуальный редизайн PDF-отчёта). Пользователь справедливо указал, что дизайн всё ещё выглядит не «премиум» — исследование 4 альтернативных Python PDF-стеков (ReportLab/Typst/Playwright/улучшенный WeasyPrint, см. предыдущий research) нашло точную причину: это execution gap в прошлом PR, не потолок возможностей WeasyPrint. - **Честный шрифт**: CSS объявлял `font-family: 'DejaVu Serif'/'DejaVu Sans'`, но ни одного `@font-face` не было — документ всегда рендерился на системном fallback. Теперь PT Sans (400/700) + PT Serif (700, только заголовки секций) embedded как base64 `@font-face` из локальных `.ttf` (OFL-лицензия ParaType, в `app/services/exporters/fonts/`). - **Реальный график**: `_price_range_bar` был буквально `<div>` с фиксированной 64%/14% заливкой, никак не связанной с реальными числами. Заменён на `_price_range_chart_svg` — matplotlib bullet-chart на настоящем `[range_low, range_high]`, embedded как SVG data URI. - **Найден и исправлен баг**: WeasyPrint требует явный `FontConfiguration()`, переданный и в `CSS()`, и в `write_pdf()` — без него `@font-face` молча игнорируется, рендер тихо откатывается на DejaVu. Без этого фикса шрифт бы не заработал вообще. - **Найден и исправлен смежный security-баг** (обнаружен при верификации, не связан напрямую с шрифтами): `url_fetcher` передавался только в `write_pdf()`, а в WeasyPrint 69 это молча игнорируется (`Unknown rendering option`) — SSRF-guard фактически не применялся к CSS-ресурсам (`@font-face src:`). Теперь `url_fetcher` передаётся в оба конструктора, `HTML()` и `CSS()`. - **Thread-safety**: график переведён с глобального `pyplot` (plt.subplots/plt.close) на объектный `Figure()` API — `write_pdf()` вызывается в FastAPI threadpool, конкурентные PDF-запросы не должны делить глобальный figure-manager. ## Test plan - [x] `pytest tests/test_pdf_security.py tests/services/test_trade_in_pdf_dual_price.py` — 42/42 passed (локально и внутри Docker с реальным WeasyPrint/matplotlib) - [x] `ruff check` + `ruff format` — чисто - [x] Полный backend test suite (2265 passed, 1 pre-existing unrelated flake, подтверждено идентичным на чистом main) - [x] Реальный Docker-рендер (prod-идентичный образ) — `pdffonts` подтвердил PT-Sans/PT-Sans-Bold/PT-Serif-Bold embedded (не silent fallback); графики визуально проверены на всех страницах, где используются (cover ×2, listings, deals) - [x] Код-инспекция WeasyPrint 69 internals — подтверждено, что `url_fetcher` на `CSS()` реально доходит до `@font-face` resolution (`preprocess_stylesheet` → `font_config.add_font_face`) - [x] code-reviewer pass — ⚠️ MINOR (2 пункта, оба исправлены в этом же PR: url_fetcher-wiring + pyplot thread-safety) ## Известные ограничения (не блокеры, follow-up) - В worst-case сценарии (полный disclaimer + все 3 advice-строки + оба заполненных диапазона) cover-страница переполняется на дополнительную почти-пустую физическую страницу (5 страниц вместо 4) — косметика, не потеря контента.
lekss361 added 1 commit 2026-07-04 14:34:30 +00:00
fix(tradein/pdf): честный embedded-шрифт (PT Sans/PT Serif) + реальный matplotlib-график вместо div
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 54s
5494fa23e3
Корень жалоб «дизайн не премиум»/«бары самодельные» — не в WeasyPrint, а в
execution gap: CSS объявлял font-family DejaVu Serif/Sans, но ни один
@font-face не embedded — документ всегда рендерился на системном
fallback-шрифте. Price-range bar был буквально <div> с фиксированной
64%/14% заливкой, никак не связанной с реальными числами диапазона.

Подтверждено 4 параллельными прототипами (ReportLab/Typst/Playwright/
улучшенный WeasyPrint) на одном и том же образце: WeasyPrint с честным
шрифтом и настоящим графиком визуально не уступает более дорогим
альтернативам (Chromium, Typst) при нулевом новом риске в Docker.

Изменения:
- PT Sans (400/700) + PT Serif (700, только для заголовков секций) —
  base64 @font-face из локальных .ttf (app/services/exporters/fonts/,
  OFL-лицензия ParaType), embedded один раз при импорте модуля.
- Обязательный WeasyPrint FontConfiguration() — без него @font-face
  молча игнорируется и рендер откатывается на DejaVu (реальный баг,
  найденный и исправленный в процессе).
- _price_range_bar (декоративный div) → _price_range_chart_svg —
  matplotlib (Agg backend, объектный Figure() API, не pyplot — потокобезопасно
  для конкурентных PDF-запросов в FastAPI threadpool) bullet-chart на
  реальном диапазоне значений, embedded как SVG data URI.
- url_fetcher теперь передаётся в HTML() и CSS() конструкторы, а не в
  write_pdf() — в WeasyPrint 69 write_pdf(url_fetcher=...) молча
  игнорируется, из-за чего SSRF-guard фактически не применялся к
  CSS-ресурсам (@font-face src). Побочная находка при верификации, не
  связанная с шрифтами/графиком напрямую, но тот же вызов кода.
lekss361 merged commit 5cbf3485a0 into main 2026-07-04 14:39:38 +00:00
lekss361 deleted branch fix/tradein-pdf-real-fonts-chart 2026-07-04 14:39:38 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2427
No description provided.