装闭 RenoPit 源码解析(13):生成AI装修闭坑PDF报告

网页报告适合在线浏览,装修沟通中还需要可以保存和转发的 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:

  1. /api/projects/{id}/result 获取与 React 相同的聚合结果;
  2. /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,
)

文档注册 CoverContent 两套 PageTemplate。封面模板负责首页样式,内容模板负责后续页眉、页脚和页码。doc.build(story) 将 Flowable 列表排版到 PDF,最后从 BytesIO 取出字节。

四、story 如何组织报告章节

_build_content() 按固定顺序向 story 添加 ParagraphTableSpacerImagePageBreak
#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 如何组成项目运行环境。

相关推荐
独行侠影a2 小时前
Mojo:专为AI而生的“Python++”,能否真正挑战CUDA与C++的统治地位?
大数据·人工智能·深度学习
zzzzzz3102 小时前
别让大模型直接碰业务:我在 Spring Boot 里给 AI 操作加了一道“可拒绝的闸门”
人工智能·spring boot·spring
2601_960906722 小时前
一致行动人合计持股比例变动超过1%整数倍
人工智能·逻辑回归·爬山算法·散列表·启发式算法·广度优先
蓝鲨硬科技2 小时前
任利锋的“造物”野心,让AI 3D进入“可制造”时代
人工智能·3d·制造
暗黑小白2 小时前
Agent 运行时与 Harness:从教科书循环到生产级运行时
人工智能
良凯尔2 小时前
驾驭AI Coding:用Graphify为AI编程助手装上代码图谱引擎
ai
烂蜻蜓2 小时前
AI入门教程(十七):AI安全进阶——越狱、注入、对抗与防护
人工智能·ai
小K讲AI营销2 小时前
存储定价权重构:用“产能分配“框架重估 DRAM+46%、NAND+65%
人工智能