"""形象报告 PDF 渲染器:result JSONB → PDF bytes(无 I/O,纯内存渲染)。 输入是 ``rp_report.result``(diagnosis/copy/validation 三段产物),输出 PDF 字节串;落盘、回写 ``rendered_asset_key`` 归 worker 任务,下载归路由—— 渲染器本身只关心「内容 → 版式」,便于单测与将来换排版。 安全约定(WeasyPrint 能加载外部资源,等于一个 SSRF 面): 模板不引用任何外部 URL,且自定义 url_fetcher 对**一切** URL(含 file://) 直接拒绝——未来模板误加 ```` 也只是渲染失败,绝不会把本地文件 或内网资源打进 PDF。 维度中文名不在 ``diagnosis.dimensions`` 行内(DimensionResult 只有 key), 渲染时按 key 回填 ``DIMENSION_BY_KEY`` 的字典定义——PDF 展示口径与流水线 产物口径各管各的,产物不冗余存展示字段。 """ from __future__ import annotations import json from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any, NoReturn import jinja2 from weasyprint import HTML from packages.report.dimensions import DIMENSION_BY_KEY from packages.shared.errors import AppError, ErrorCode _TEMPLATE_DIR = Path(__file__).parent / "templates" #: 档案摘要页「核心结论」卡片的维度键:体/脸/季 + 风格三件套是报告的画像锚点 #: (与 copy.py HARD_DIMENSION_KEYS 的取舍同源——这些是用户最先看的结论)。 _CORE_DIM_KEYS: tuple[str, ...] = ( "body_shape", "face_shape", "skin_tone", "season_code", "gene_style", "psyche_style", "fusion_style_name", "core_goals", ) #: 值里可能带十六进制色、值得渲染成色卡的维度键。 _SWATCH_DIM_KEYS: frozenset[str] = frozenset( {"best_colors", "neutral_colors", "avoid_colors", "hair_color"} ) _env = jinja2.Environment( loader=jinja2.FileSystemLoader(_TEMPLATE_DIR), autoescape=jinja2.select_autoescape(default=True, default_for_string=True), trim_blocks=True, lstrip_blocks=True, ) @dataclass(frozen=True, slots=True) class ReportPdfMeta: """渲染元信息(报告主体之外的归属字段,由 worker 从 rp_report 行带入)。""" report_id: int version: int generated_at: datetime brand: str = "美搭" title: str = "个人形象美学报告" @dataclass(frozen=True, slots=True) class _DimView: """单维度的展示模型:模板保持哑渲染,格式化全部在 Python 侧完成。""" number: int key: str name_cn: str value_text: str confidence_pct: int | None # skipped 维度无置信度 evidence: str skipped: bool swatches: tuple[dict[str, str], ...] = field(default=()) def _reject_all_urls(url: str, **_options: Any) -> NoReturn: """WeasyPrint url_fetcher:本版模板零外部资源,任何拉取都是 bug 或注入。""" raise ValueError(f"PDF 模板不允许加载外部资源:{url!r}") def _fmt_scalar(value: Any) -> str: if value is None: return "—" if isinstance(value, bool): return "是" if value else "否" if isinstance(value, str): return value if isinstance(value, int | float): return str(value) return json.dumps(value, ensure_ascii=False) def _fmt_value(value: Any) -> str: """维度结论值 → 一行展示文本(枚举/数值/列表/字典都可能出现)。""" if isinstance(value, list): parts = [] for item in value: if isinstance(item, Mapping): # 色卡类条目优先用中文名,其次 hex;其余字典整体 JSON 化。 parts.append(str(item.get("name_cn") or item.get("hex") or _fmt_scalar(dict(item)))) else: parts.append(_fmt_scalar(item)) return "、".join(parts) if parts else "—" return _fmt_scalar(value) def _extract_swatches(key: str, value: Any) -> tuple[dict[str, str], ...]: """色卡维度提取 ``{hex, name}`` 列表;非色卡维度或值形不符时为空。""" if key not in _SWATCH_DIM_KEYS or not isinstance(value, list): return () swatches: list[dict[str, str]] = [] for item in value: if isinstance(item, Mapping) and isinstance(item.get("hex"), str): swatches.append({"hex": item["hex"], "name": str(item.get("name_cn") or "")}) return tuple(swatches) def _build_dimensions(diagnosis: Mapping[str, Any]) -> list[_DimView]: raw_dims = diagnosis.get("dimensions") or [] views: list[_DimView] = [] for raw in raw_dims: if not isinstance(raw, Mapping): continue key = str(raw.get("key") or "") spec = DIMENSION_BY_KEY.get(key) skipped = raw.get("status") == "skipped" value = raw.get("value") confidence = raw.get("confidence") views.append( _DimView( number=int(raw.get("number") or (spec.number if spec else 0)), key=key, name_cn=spec.name_cn if spec else key or "未知维度", value_text="初级版未覆盖" if skipped else _fmt_value(value), confidence_pct=None if skipped or not isinstance(confidence, int | float) else round(float(confidence) * 100), evidence=str(raw.get("evidence") or ""), skipped=skipped, swatches=() if skipped else _extract_swatches(key, value), ) ) views.sort(key=lambda view: view.number) return views def render_report_pdf(result: Mapping[str, Any], *, meta: ReportPdfMeta) -> bytes: """渲染一份形象报告 PDF;产物结构不完整即 40900(内容由流水线保证)。 只对 ``succeeded`` 报告调用(路由/worker 已把关),这里仍校验 copy 存在——渲染器不知道上游纪律,结构缺口必须在自己手里变成结构化错误, 而不是 Jinja 渲染一半的 500。 """ copy = result.get("copy") diagnosis = result.get("diagnosis") if not isinstance(copy, Mapping) or not isinstance(diagnosis, Mapping): raise AppError(ErrorCode.STATE_CONFLICT, message="报告内容不完整,无法导出 PDF") summary = str(copy.get("summary") or "") sections: list[dict[str, Any]] = [] for section in copy.get("sections") or []: if not isinstance(section, Mapping): continue sections.append( { "title": str(section.get("title") or ""), "paragraphs": [str(p) for p in section.get("paragraphs") or []], } ) dimensions = _build_dimensions(diagnosis) # 核心卡片按字典键集合挑,保持档案摘要页聚焦画像锚点 core_keys = set(_CORE_DIM_KEYS) core_views = [v for v in dimensions if v.key in core_keys and not v.skipped] template = _env.get_template("report.html.j2") html = template.render( meta=meta, date_text=meta.generated_at.strftime("%Y年%m月%d日"), summary=summary, sections=sections, dimensions=dimensions, core_dimensions=core_views, ok_count=sum(1 for v in dimensions if not v.skipped), ) # weasyprint 的 write_pdf 声明返回 Any(其 typeshed 未收口),按声明类型收窄 pdf: bytes = HTML(string=html, base_url=None, url_fetcher=_reject_all_urls).write_pdf() return pdf