ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot + Thymeleaf + Flying Saucer:纯Java实现HTML转PDF导出

2026/9/8 6:54:03 拓冰建站 浏览量
Spring Boot + Thymeleaf + Flying Saucer:纯Java实现HTML转PDF导出 在 Java 后端里处理 PDF 导出绕不开三件事先把页面内容用 Thymeleaf 渲染成结构化 HTML再把 HTML 干干净净地转成 PDF最后解决那些说不清道不明的中文字体和分页问题。我最近几个报表导出项目全程用 Spring Boot Thymeleaf Flying Saucer 这套组合一句话概括就是用写网页的方式写 PDF在 Java 进程内不依赖任何外部浏览器完成 PDF 生成。如果你已经在用 iText 手写文档对象或者被 wkhtmltopdf、无头 Chrome 的环境依赖折磨过这篇文章正好帮你打开另一条路。我会从方案选型、Maven 依赖、字体处理、模板编写、核心封装、CSS 兼容性、常见坑位完整梳理一遍每段都有可以直接抄作业的代码和配置。不管你是第一次接触 PDF 导出还是已经踩了几个坑正在找解法这套流程都能给到实用的参考。1. 方案选型为什么偏偏是这三件套1.1 PDF 生成的几种常见思路Java 生态里做 PDF主流方案大概分三类。第一类是直接用 iText、OpenPDF、PDFBox 这类底层库在代码里创建 Document 对象、手动加 Paragraph、加 Table一个字一个字地拼文档。优点是控制力极强缺点是开发效率实在低。一个稍微像样的销售订单包含标题、客户信息、商品表格、金额汇总、盖章区域用代码拼下来至少两三百行而且后续改排版等于重写维护成本很高。第二类是模板引擎方案先用 Freemarker、Velocity、Thymeleaf 渲染 HTML再用工具把 HTML 转 PDF。这里又分两派一派是常见的专业 HTML 转 PDF 工具另一派就是 Flying Saucer。Flying Saucer原 xhtmlrenderer用 Java 实现 XHTML CSS 2.1 解析借助底层 iText 2.x 生成 PDF说白了就是喂给它一个 HTML 字符串它给你吐出一个 PDF 流排版工作全部交给模板和 CSS 解决。第三类是调用浏览器无头模式比如 Puppeteer 无头 Chrome 打印 PDF。这种方案对 CSS 支持最好前端怎么画 PDF 就怎么长但缺点也很致命Java 服务端要起一个 Chromium 进程环境依赖重并发导出时内存和 CPU 开销极大压测时很容易把服务拖垮。对比下来Spring Boot 项目里如果 PDF 模板偏报表型布局以表格和简单块级元素为主Thymeleaf Flying Saucer 的性价比最高。它不依赖外部进程、不依赖系统已安装的浏览器纯 Java 跑部署方式和普通 Spring Boot 服务完全一致Docker 镜像里也不用额外装任何东西。提示如果你的 PDF 排版需要 flex、grid、圆角阴影这类现代 CSS 特性那 Flying Saucer 不是最佳选择建议直接上无头浏览器方案。Flying Saucer 的 CSS 支持停留在 2.1 时代这是它的边界后文我会详细讲。1.2 Flying Saucer 与同类工具的参数对比为了让你更清楚地做决策我整理了一张对比表。先说清楚这张表只针对 Java 服务端场景不涉及桌面端解决方案。工具依赖外部进程CSS 支持中文支持分页控制适合场景iText / OpenPDF否无纯代码布局需注册字体手动控制复杂表单、填表盖章Flying Saucer否CSS 2.1 大部分需注册字体中等靠 CSS 分页报表、订单、清单wkhtmltopdf是需安装工具CSS 2.1 部分 CSS3依赖系统字体比较好内容较复杂的单页报告无头 Chrome (Puppeteer)是需安装 ChromiumCSS3 基本全支持依赖系统字体很好高保真前端页面转 PDFFlying Saucer 的核心竞争力就是零外部依赖 纯 Java 渲染。这在容器化部署、微服务隔离、云函数环境下优势非常明显。你不需要在 Dockerfile 里额外装浏览器也不用担心生产环境缺某个系统库。它的劣势同样明确CSS 版本老JavaScript 完全不执行所以不要在 PDF 模板里写任何 JS 逻辑写了也白写。1.3 为什么模板引擎选 ThymeleafSpring Boot 里模板引擎的选择其实不少Freemarker、Velocity、Thymeleaf 都能干活。我选 Thymeleaf 的原因有两点。第一层是生态契合。Thymeleaf 是 Spring Boot 官方推荐的模板引擎与 Spring MVC 集成非常顺畅。Spring Boot 的自动配置里直接带 ThymeleafAutoConfiguration你在 templates 目录下放一个 html 文件注入 TemplateEngine调用 process 方法就能拿到渲染后的字符串。这套流程几乎不需要额外配置开箱即用。第二层是模板表达能力。Thymeleaf 的 th:each、th:if、th:with、#numbers、#strings 这些工具函数处理报表数据非常顺手。比如订单明细循环、金额格式化、空值兜底在模板里三五行写完比在 Java 代码里用 StringBuilder 拼字符串清爽太多。另外Thymeleaf 模板本身就是 HTML 文件写模板的时候直接用浏览器打开就能看效果。我在开发报表模板时有个固定习惯先造一份假数据渲染成 HTML直接在浏览器里检查样式对不对再调整 CSS最后才接 Flying Saucer 转 PDF。这个工作流比纯代码画 PDF 友好太多了。2. 环境准备与工程搭建2.1 Maven 依赖配置直接上 Maven 依赖核心就一个 flying-saucer-pdf外加 Spring Boot 自带的 Web 和 Thymeleaf。我的 pom.xml 里相关部分是这样写的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.xhtmlrenderer/groupId artifactIdflying-saucer-pdf/artifactId version9.1.22/version /dependency注意版本。Flying Saucer 目前在 Maven 中央仓库主推 9.x 分支9.1.22 是我最近一次用到的版本兼容性没问题。网上大量博客写的是 1.x 或 2.x那是非常老的版本不建议再用。如果你在 9.x 里遇到某些类找不到的情况大概率是下载包不完整或者本地仓库缓存问题clean 一下再重新拉取。flying-saucer-pdf 9.x 内部把 iText 2.1.7 的类重命名并打包进自己的 jar 里了所以不用再手动引入 iText。这一点算是很大的改善老版本经常因为 iText 冲突出现 NoClassDefFoundError新版省了不少心。注意不要在你的项目里同时引入高版本 iText 和 flying-saucer-pdf两者工具类路径如果有冲突运行时会出现各种诡异异常。我建议彻底依赖 Flying Saucer 自带的渲染能力如果确实需要 iText 原生功能再单独评估是否切换方案。2.2 Thymeleaf 模板目录与资源规划按 Spring Boot 默认约定页面模板放在 src/main/resources/templates 下。为了和普通网页区分我习惯建一个子目录 pdf 专门放 PDF 模板这样目录结构一目了然src/main/resources/templates/pdf/ ├── order.html └── settlement.html静态资源目录我建议单独建 src/main/resources/pdf-assets放置 PDF 模板里用到的图片、CSS、字体。为什么不用默认的 static 目录因为 Flying Saucer 解析 HTML 时需要 baseUrl 来拼接相对路径的图片和 CSS如果你把图片放到 static 下拼接路径时会多绕一层。创建 PDF 时给 renderer.setDocumentFromString(html, baseUrl) 传入一个指向 pdf-assets 目录的 file:/// 路径模板里写相对路径就行。字体文件我习惯放在 src/main/resources/fonts 下打包时它会一起进 classpath。运行时用 Spring 的 ClassPathResource 拿到真实文件路径再注册给 Flying Saucer避免硬编码外部绝对路径导致部署环境不一致。后文字体章节会给完整代码。2.3 中文字体处理——最容易翻车的一环如果你跳过字体配置直接拿含中文的 HTML 去生成 PDF出来的文件里中文十有八九是方框或者空白。原因是 Flying Saucer 底层调用 iText 渲染文本时需要一个支持中文字符集的字体否则字符映射不上。解决办法是注册系统字体或项目内置字体。我推荐把字体文件放到项目里这样不受生产操作系统环境限制。常见的开源中文字体里思源黑体Source Han Sans和思源宋体Source Han Serif比较靠谱文件体积大一点但胜在稳定。这类字体是 OFL 协议商用没有后顾之忧。字体注册代码的核心思路是字体文件从 classpath 里复制出来然后调用 resolver.addFont()public void registerFonts(ITextFontResolver resolver) throws Exception { File hei copyFontFromClasspath(fonts/SourceHanSansCN-Regular.otf); resolver.addFont(hei.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); File heiBold copyFontFromClasspath(fonts/SourceHanSansCN-Bold.otf); resolver.addFont(heiBold.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); File song copyFontFromClasspath(fonts/SourceHanSerifCN-Regular.otf); resolver.addFont(song.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); } private File copyFontFromClasspath(String path) throws IOException { ClassPathResource resource new ClassPathResource(path); File tempFile File.createTempFile(font, .otf); try (InputStream in resource.getInputStream()) { Files.copy(in, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); } tempFile.deleteOnExit(); return tempFile; }BaseFont.IDENTITY_H 是 iText 中表示 Unicode 横向编码的常量配合 NOT_EMBEDDED 表示不嵌入字体文件。如果你希望 PDF 在别人机器上也保持完全一致的显示效果可以改成 EMBEDDED但文件体积会大不少。顺带说一句OTF 格式在 Flying Saucer 里偶尔会有解析问题如果遇到字体加载失败优先转成 TTF 再试。 如果你的项目对体积敏感也可以用精简字体子集但那是另一个话题了报表场景下我一般直接用全量字体文件省心。3. 核心代码实现3.1 封装 PDF 导出服务开发 PDF 导出功能我不建议在每个接口里堆重复代码应该封装一个 PdfExportService把模板渲染和 PDF 生成统一收敛。核心逻辑很简单接收模板名称和数据模型用 Thymeleaf 渲染出 HTML 字符串再用 Flying Saucer 渲染成 PDF 输出流。下面是一段经过验证的代码我把注释写得很详细Component public class PdfExportService { private final TemplateEngine templateEngine; public PdfExportService(TemplateEngine templateEngine) { this.templateEngine templateEngine; } public void writePdf(String templateName, MapString, Object model, OutputStream outputStream) throws Exception { Context context new Context(); context.setVariables(model); String html templateEngine.process(templateName, context); ITextRenderer renderer new ITextRenderer(); ensureFontRegistered(renderer); renderer.setDocumentFromString(html, PDF_ASSETS_BASE_URL); renderer.layout(); renderer.createPDF(outputStream); renderer.finishPDF(); } public String renderHtml(String templateName, MapString, Object model) { Context context new Context(); context.setVariables(model); return templateEngine.process(templateName, model); } private static volatile boolean fontRegistered false; private synchronized void ensureFontRegistered(ITextRenderer renderer) throws Exception { if (!fontRegistered) { ITextFontResolver resolver renderer.getFontResolver(); registerFonts(resolver); fontRegistered true; } } }细看这段代码有几个关键点值得解释。第一baseUrl 参数。setDocumentFromString 的第二个参数我传的是 pdf-assets 目录的 file:/// 绝对路径。这个值建议配置成 Spring 配置项通过 application.yml 注入换环境不用改代码。生产环境这个路径要确保有访问权限。第二字体注册的并发控制。ensureFontRegistered 用了 volatile synchronized 双重检查目的是避免每次导出都重复注册字体同时也保证并发场景下不会重复执行 addFont。这个优化在小并发下感知不明显但高并发导出时能省不少 IO。第三finishPDF 的调用。createPDF 之后如果不调用 finishPDF在部分场景下输出流结尾可能不完整。虽然单文档场景下 createPDF 内部会调用 finishPDF但养成习惯显式调用更稳妥尤其在后续要扩展页脚水印时。3.2 Controller 接口与响应头设置有了封装服务Controller 层就非常薄了。下面是一个标准的订单 PDF 导出接口GetMapping(/order/{id}/pdf) public ResponseEntitybyte[] exportOrderPdf(PathVariable Long id) throws Exception { Order order orderService.getById(id); MapString, Object model new HashMap(); model.put(order, order); model.put(createdAt, LocalDate.now()); ByteArrayOutputStream baos new ByteArrayOutputStream(); pdfExportService.writePdf(pdf/order, model, baos); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentDispositionFormData(attachment, order- id .pdf); return new ResponseEntity(baos.toByteArray(), headers, HttpStatus.OK); }这里响应头有两个细节。第一个是 Content-Type 必须设置成 application/pdf否则浏览器可能当成文本直接打开乱码。第二个是 Content-Disposition 用 attachment 指定下载文件名如果想让浏览器内联预览改成 inline 即可。对于超大 PDF建议不要用 ByteArrayOutputStream 全部载入内存而是直接把输出流交给 HttpServletResponse。比如 Controller 接收 HttpServletResponse 参数把 response.getOutputStream() 传给 PdfExportService这样大文件导出时内存占用更可控。不过如果是普通企业应用导出文件一般也就几百 KB 到几 MBByteArrayOutputStream 完全够用。3.3 Controller 与 Service 的职责划分我在很多项目里见过一个问题Controller 里又是查库、又是算金额、又是拼模型最后还要处理输出流这种代码十有八九撑不过几次迭代就变成一坨。我推荐的分层规则很明确Controller 只负责接收请求参数、设置响应头PDF 导出的数据组装逻辑放在 Service模板渲染和 PDF 生成完全收敛在 PdfExportService。这样做的好处是同一套 PDF 逻辑可以被多个接口复用比如订单单个导出、订单批量导出可以共用同一个模板和渲染服务。还有一个小技巧建议在 Service 层留一个渲染 HTML 但不转 PDF的方法。我在前文的 PdfExportService 里已经写了 renderHtml 方法。开发调试时非常有用在接口后面加个 debugtrue 参数直接返回 HTML浏览器打开就能看排版效果不需要每次改完模板都下载 PDF 再打开 PDF 查看。这个习惯帮我节省了巨量调试时间。4. 样式兼容实战4.1 Flying Saucer 的 CSS 支持边界Flying Saucer 对 CSS 2.1 的支持比较完整但对 CSS3 的支持非常有限。它不认识 flex不认识 grid不认识 position fixed也很少支持 border-radius 和 box-shadow。这些在普通网页里天天用的属性在 Flying Saucer 里写了等于白写。所以设计 PDF 模板时一定要用老派的流式布局加表格布局思维。简单的页面结构用 div 块从上往下排复杂的对齐关系用 table 控制。table 虽然被前端嫌弃多年但它在 PDF 渲染引擎里是最稳定的排版工具没有之一。我常用的几大块写法整页容器不需要 max-width默认宽度即可通过 page 控制纸张和边距页眉放在表格第一行的 td 里或者用 position runningFlying Saucer 支持该特性但语法比较特殊两栏区域用 table 的两列不要用 float 或 flex间距用 margin 和 padding不要用 gapgap 在 Flying Saucer 里经常不生效4.2 分页与页码控制的正确姿势分页是 PDF 报表里绕不开的话题。Flying Saucer 支持 CSS 2.1 里的 page-break-before 和 page-break-after这两个属性告诉渲染引擎在哪里切断页面。比如每一章前面加 page-break-before: always就能强制从新页开始。page 规则里可以做边距设置page { size: A4; margin: 20mm 15mm 20mm 15mm; }页码方面Flying Saucer 提供了一些特殊 CSS 函数比如 content: counter(page) 和 content: counter(pages)。一个我在项目中实测可用的分页示例page { size: A4; margin: 25mm 15mm 20mm 15mm; bottom-center { content: 第 counter(page) / counter(pages) 页; font-size: 9px; color: #666; } }这个写法在 Flying Saucer 9.x 中可以正常工作但有个前提HTML 文档必须声明为 XHTML 格式DOCTYPE 最好是 XHTML 1.0 strict不然某些 CSS 规则解析会异常。这一点很容易被忽略我第一次用的时候整整折腾了一个下午最后才发现是 DOCTYPE 的问题。4.3 常用 PDF 模板骨架与样式示例下面给一个可以直接复制的 PDF 模板骨架包含报表最常见的几类元素!DOCTYPE html PUBLIC -//W3C//DTD XHTML 1.0 Transitional//EN http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd html xmlnshttp://www.w3.org/1999/xhtml xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 / title订单报表/title style page { size: A4; margin: 20mm 15mm; bottom-center { content: 第 counter(page) / counter(pages) 页; font-size: 9px; color: #666; } } body { font-family: Source Han Sans CN, 思源黑体, sans-serif; font-size: 12px; color: #333; line-height: 1.6; } .header { text-align: center; margin-bottom: 15px; } .header h1 { font-size: 22px; margin-bottom: 5px; } table { width: 100%; border-collapse: collapse; margin-bottom: 10px; } th, td { border: 1px solid #000; padding: 6px 8px; text-align: left; } th { background-color: #f2f2f2; } .text-right { text-align: right; } .total-row { font-weight: bold; margin-top: 10px; } /style /head body div classheader h1销售订单/h1 p订单编号span th:text${order.orderNo}ORDER123/span/p p下单时间span th:text${#temporals.format(order.createdAt, yyyy-MM-dd HH:mm)}2025-01-01 10:30/span/p /div table thead tr th序号/th th商品名称/th th数量/th th单价/th th小计/th /tr /thead tbody tr th:eachitem, stat : ${order.items} td th:text${stat.count}1/td td th:text${item.productName}测试商品/td td classtext-right th:text${item.quantity}2/td td classtext-right th:text${#numbers.formatDecimal(item.price, 1, 2)}100.00/td td classtext-right th:text${#numbers.formatDecimal(item.amount, 1, 2)}200.00/td /tr /tbody /table div classtotal-row p总金额span th:text${#numbers.formatDecimal(order.totalAmount, 1, 2)}1000.00/span 元/p /div /body /html注意模板里所有 CSS 属性都保守一点优先用 2.1 支持的老属性。比如不要用 display: flex不要用 position: sticky不要用 CSS 变量。模板写完后先在浏览器里看一眼大概布局再跑一次 PDF 转换确认最终效果。这个流程能避免很多返工。4.4 页眉页脚与水印的扩展实现如果要给 PDF 加页眉页脚内容比如公司名称、文档编号、水印Flying Saucer 的方案相对传统。页眉页脚除了 page 里的 bottom-center 写法还能通过 position: running 把文档流中的元素指定为页眉或页脚。举个例子把订单编号放进页脚右侧.order-no { position: running(orderNo); } page { bottom-right { content: element(orderNo); } }然后在 HTML 里给任意一个元素加上 classdiv classorder-no th:text${order.orderNo}ORDER123/div这个 div 在正文中不会显示但会出现在每页页脚的指定位置。这个特性 Flying Saucer 支持不过语法比较偏门用的时候要仔细测试不同页的效果。水印的做法通常是通过绝对定位一个半透明的大字或者图片铺在页面中间配合 page margin box 实现复杂一点的建议直接在后处理阶段用 PDF 库再叠一层。5. 常见问题与排查技巧实录5.1 中文全部变成方框或空白这是遇到最多的一个问题。排查路径很固定第一步确认字体文件能读出来第二步确认 addFont 执行成功第三步确认 CSS 里 font-family 的名称和字体实际名称对得上。我自己踩过一次最冤枉的坑是字体文件放在 resources 下本地开发时 ClassPathResource 能拿到绝对路径但打成 jar 包后getFile() 会抛异常因为 jar 包里的文件不是传统文件系统文件。解决办法就是我前面代码里写的那样把字体提取到临时目录再注册这个写法在本地和 jar 包部署下都稳定。另一个容易忽略的是字体族名称大小写。Flying Saucer 对字体名称匹配是区分大小写的如果 CSS 里写 Microsoft YaHei实际字体内部名称是 Microsoft YaHei但你在某个配置里写成了 microsoft yahei就会匹配不上。稳妥起见CSS 的 font-family 写一个主要的再加一个常见 fallback 兜底。5.2 表格边框显示不全或消失Flying Saucer 对 border-collapse: collapse 的支持有历史问题某些版本下会导致 td 边框丢失。最直接的应对策略是每个单元格都显式声明 border包括颜色和宽度不要只给 table 设置边框。另外一个细节是td 里不要连续出现空内容如果没有数据就写一个非断行空格否则单元格高度可能塌陷。如果遇到表格跨页时边框断裂可以在 thead 上设置重复表头。Flying Saucer 支持 thead 跨页重复显示只要把表头放在 thead 里分页时会自动在新页顶部绘制表头。这个特性配合 page-break-inside: avoid 可以很大程度提升跨页表格的可读性。5.3 图片不显示Flying Saucer 加载图片依赖 baseUrl。如果你用相对路径而 baseUrl 没设置或目录不对图片就会空白。调试方式分两步先确认 HTML 字符串里 img 标签的 src 是相对路径再确认传给 setDocumentFromString 的 baseUrl 目录里真的存在这个资源。还有一个高频问题图片有路径但生成后不显示。这种情况多半是 img 标签没有显式指定宽高。Flying Saucer 在 layout 阶段如果拿不到图片的渲染尺寸最后生成的 PDF 里图片会是 0 宽 0 高。我在写模板时给所有 img 都带 width 和 height 属性哪怕只写一句 stylewidth: 100px; height: 50px;也能避开这个坑。5.4 分页后内容被截断或被页眉遮挡分页调整没有银弹。我的经验是少用 CSS 魔法多靠自然流分页。确需强制分页时用 page-break-before: always 或 page-break-after: always。如果出现某一行文字被截断成两半跨页可以在 tr 或 p 上设置 page-break-inside: avoid让这行整体移到下一页。另外表格 td 里设置 min-height 有时也会干扰分页计算。建议把高度控制都转成 padding 和 line-height减少分页时的意外。还有一例page 的 margin 设置和页面底部边注重叠时页脚可能遮挡正文。解决方案是加大 page 的底部 margin留出足够空间给页脚。5.5 并发导出时的内存与性能优化Flying Saucer 是纯 Java 渲染内存占用和 HTML 复杂度、图片数量高度相关。导出频率低时完全不用在意但接口一旦被高频调用就要做限制和缓存。我的处理方式有几个。第一PDF 文件用 ByteArrayOutputStream 输出没问题但超大文件建议直接写 HttpServletResponse 的输出流避免同时撑多份内存。第二模板里如果引用了静态图片图片文件可以加本地缓存避免 Repeatedly 读磁盘。第三在服务端做并发控制用 Semaphore 或线程池限制同时执行的 PDF 导出任务数防止几十个导出请求同时跑然后把堆内存打爆。我在实际项目里的做法是用 Spring 的 TaskExecutor 把导出请求排队超过 N 个并发直接抛异常提示用户稍后再试。这个 N 要根据服务器内存来定一般 2C4G 的机器并发导出控制在 5 个以内比较稳。毕竟导出本身是 IO 密集 CPU 密集的任务没必要让几十个任务互相抢资源。6. 我最后想唠叨的几点用了这么多年 PDF 生成最大的体会是方案要跟着需求走不要盲目追求技术上的炫酷。如果你的报表只需要 A4 纸、表格、地址、签名这些传统元素Flying Saucer 完全够用而且能省掉一堆外部依赖。如果你的需求慢慢复杂到要动态图表、二维码、自定义页脚、封面页那就要提前设计好架构把 PDF 生成独立成模块方便后续切换渲染引擎。还有一点不管用哪种方案一定要在开发期留一个直接看渲染后 HTML的便利通道。我习惯在导出接口后面加一个 debug 参数返回渲染后的 HTML 字符串而不是 PDF浏览器打开就能看到模板效果。这个习惯帮我省下了大量调试时间强烈建议试试。另外别忽视模板命名和目录规划。一个项目里塞了十几个 PDF 模板之后如果没有清晰的结构光是找模板文件就够让人头疼。我的习惯是按业务域建子目录一个业务域一个目录模板名和接口名保持一致后期维护起来省心很多。最后再分享一个小技巧PDF 导出这种功能一定要在开发初期就把日志打全一点。模板名、数据模型 key、baseUrl、字体注册路径这些关键信息都加到日志里。线上出问题的时候这些日志能帮你省下大把排查时间。细节决定成败PDF 导出尤其如此。