
网页报告适合在线浏览,装修沟通中还需要可以保存和转发的 PDF。装闭 RenoPit 在开源仓库 fthux/RenoPit 中使用 ReportLab,把与 React 报告页相同的数据生成 A4 中文文档。
一、前端如何发起 PDF 下载
AnalysisPage 点击下载按钮后请求 /api/projects/{id}/report/pdf。响应不是 JSON,而是 application/pdf 二进制数据。前端把它转换成 Blob,再创建临时 URL 和 <a> 元素触发下载:
tsx
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `renopit-report-${projectId.slice(0, 8)}.pdf`
a.click()
URL.revokeObjectURL(url)
下载状态由 downloading 控制,接口失败时读取后端 detail 并显示 Toast。
二、PDF 接口为什么再次调用内部 API
download_report_pdf() 没有在函数中重新实现结果转换,而是通过 httpx.AsyncClient 请求本机 FastAPI:
/api/projects/{id}/result获取与 React 相同的聚合结果;/api/projects/{id}/images?include_storage_path=true获取图片及磁盘路径。
容器内使用 http://localhost:8000,因为请求发生在 backend 容器内部。这样 PDF 与网页共享 _build_result_data()、文档序列化和交叉核查注入逻辑,不会维护第二套结果映射。
三、ReportLab 如何创建文档
generate_pdf() 首先调用 _ensure_font_registered()。如果配置的中文 TTF 字体存在,就通过 TTFont 注册;否则样式回退到 Helvetica。Docker 环境通过 FONT_PATH 指定 Noto Sans CJK 等中文字体。
生成过程使用内存缓冲区:
python
buf = io.BytesIO()
doc = BaseDocTemplate(
buf,
pagesize=A4,
leftMargin=25 * mm,
rightMargin=25 * mm,
topMargin=25 * mm,
bottomMargin=25 * mm,
)
文档注册 Cover 和 Content 两套 PageTemplate。封面模板负责首页样式,内容模板负责后续页眉、页脚和页码。doc.build(story) 将 Flowable 列表排版到 PDF,最后从 BytesIO 取出字节。
四、story 如何组织报告章节
_build_content() 按固定顺序向 story 添加 Paragraph、Table、Spacer、Image 和 PageBreak:
#mermaid-svg-pkIRtmIWL9ttNRhX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pkIRtmIWL9ttNRhX .error-icon{fill:#552222;}#mermaid-svg-pkIRtmIWL9ttNRhX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pkIRtmIWL9ttNRhX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pkIRtmIWL9ttNRhX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pkIRtmIWL9ttNRhX .marker.cross{stroke:#333333;}#mermaid-svg-pkIRtmIWL9ttNRhX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pkIRtmIWL9ttNRhX p{margin:0;}#mermaid-svg-pkIRtmIWL9ttNRhX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pkIRtmIWL9ttNRhX .cluster-label text{fill:#333;}#mermaid-svg-pkIRtmIWL9ttNRhX .cluster-label span{color:#333;}#mermaid-svg-pkIRtmIWL9ttNRhX .cluster-label span p{background-color:transparent;}#mermaid-svg-pkIRtmIWL9ttNRhX .label text,#mermaid-svg-pkIRtmIWL9ttNRhX span{fill:#333;color:#333;}#mermaid-svg-pkIRtmIWL9ttNRhX .node rect,#mermaid-svg-pkIRtmIWL9ttNRhX .node circle,#mermaid-svg-pkIRtmIWL9ttNRhX .node ellipse,#mermaid-svg-pkIRtmIWL9ttNRhX .node polygon,#mermaid-svg-pkIRtmIWL9ttNRhX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pkIRtmIWL9ttNRhX .rough-node .label text,#mermaid-svg-pkIRtmIWL9ttNRhX .node .label text,#mermaid-svg-pkIRtmIWL9ttNRhX .image-shape .label,#mermaid-svg-pkIRtmIWL9ttNRhX .icon-shape .label{text-anchor:middle;}#mermaid-svg-pkIRtmIWL9ttNRhX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pkIRtmIWL9ttNRhX .rough-node .label,#mermaid-svg-pkIRtmIWL9ttNRhX .node .label,#mermaid-svg-pkIRtmIWL9ttNRhX .image-shape .label,#mermaid-svg-pkIRtmIWL9ttNRhX .icon-shape .label{text-align:center;}#mermaid-svg-pkIRtmIWL9ttNRhX .node.clickable{cursor:pointer;}#mermaid-svg-pkIRtmIWL9ttNRhX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pkIRtmIWL9ttNRhX .arrowheadPath{fill:#333333;}#mermaid-svg-pkIRtmIWL9ttNRhX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pkIRtmIWL9ttNRhX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pkIRtmIWL9ttNRhX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pkIRtmIWL9ttNRhX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pkIRtmIWL9ttNRhX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pkIRtmIWL9ttNRhX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pkIRtmIWL9ttNRhX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pkIRtmIWL9ttNRhX .cluster text{fill:#333;}#mermaid-svg-pkIRtmIWL9ttNRhX .cluster span{color:#333;}#mermaid-svg-pkIRtmIWL9ttNRhX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pkIRtmIWL9ttNRhX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pkIRtmIWL9ttNRhX rect.text{fill:none;stroke-width:0;}#mermaid-svg-pkIRtmIWL9ttNRhX .icon-shape,#mermaid-svg-pkIRtmIWL9ttNRhX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pkIRtmIWL9ttNRhX .icon-shape p,#mermaid-svg-pkIRtmIWL9ttNRhX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pkIRtmIWL9ttNRhX .icon-shape .label rect,#mermaid-svg-pkIRtmIWL9ttNRhX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pkIRtmIWL9ttNRhX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pkIRtmIWL9ttNRhX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pkIRtmIWL9ttNRhX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 封面与基本信息
综合评分
严重程度统计
装修问题详情
合同与报价单风险
增项预测
跨文档核查
设计图纸
没有文档、增项或图片时,对应章节会被跳过。文档分析按创建时间倒序排列,增项预测只渲染找到的第一组结果。
五、评分和风险卡片如何绘制
评分卡使用 Table 创建数字、等级和进度条。分数低于 60 使用高风险色,60~79 使用中风险色,80 以上使用强调色;等级文字与网页报告保持一致。
每个装修问题由 _build_pitfall_card() 生成表格卡片。标题行包含风险圆点和严重程度,后续行按数据情况追加分类、位置、问题分析、陷阱说明和建议。所有来自 LLM 的文本先经过 _sanitize_html(),避免特殊字符破坏 ReportLab 的 Paragraph 标记。
合同风险、增项预测和跨文档差异各自有独立构建函数,最终都返回 Flowable 或更新后的 story。
六、设计图片如何嵌入
_build_image_section() 根据 storage_path 检查图片存在,再使用 Pillow 读取尺寸。图片会在 160mm × 120mm 范围内等比缩放,然后作为 ReportLab Image 加入文档。
函数还会查找分析问题中的 bbox 和位置描述。如果问题能够与当前图片名对应,就缩放坐标并在图片下方列出相关风险;没有定位信息时显示"该图片未检测到具体问题位置"。
七、Report 记录和 HTTP 响应
PDF 生成成功后,接口查询最近一次已完成分析。如果项目还没有 Report 记录,就创建一条包含项目 ID、分析 ID 和文件路径的记录。记录保存失败只写警告,不阻止本次字节响应返回。
最终响应设置 Content-Disposition,文件名使用项目 UUID 前八位。前端下载结束后释放临时 Object URL。
八、PDF 生成链路小结
RenoPit 的 PDF 链路是"复用结果 API、收集图片路径、构造 Flowable、内存生成字节、记录 Report、浏览器 Blob 下载"。网页与 PDF 消费同一份聚合数据,因此问题数量、评分和文档章节保持一致。
完整的 ReportLab 样式和章节构建代码位于 fthux/RenoPit。最后一篇将分析不启动后端也能运行的 Demo 模式,以及健康检查、Nginx 和 Docker Compose 如何组成项目运行环境。