
做后端系统的同学早晚会遇到一个需求把数据导成PDF。合同、单据、报表、对账单这类文件既不能改来改去又需要排版整齐PDF自然成了首选。以前我试过用iText慢慢画表格、画坐标那叫一个痛苦改个字段位置就得重新调整坐标值客户提一个新需求代码要折腾一整天。后来换成了SpringBoot结合Thymeleaf模板生成PDF整个思路就顺了先写一份HTML模板把样式、布局都定好运行的时候把数据一键填充进去再把渲染好的HTML转成PDF。配合SpringBoot开发效率和改版速度都提了一大截。这篇文章就围绕这套方案展开从环境搭建、模板设计、动态渲染到PDF输出一步步拆开细讲再把我在实际项目中踩过的字体、样式、路径这些坑一起分享出来如果你也要做类似功能可以直接照着抄。1. 场景解读为什么用Thymeleaf来生成PDF1.1 常见PDF生成方案对比PDF生成在Java生态里方案不少我挑几个常见的对比一下。第一种是直接用iText/OpenPDF这类底层库手写PDF。这种方式控制最细但代码量大写一个带表格、图片的页面要几百行不说后期维护简直是灾难。稍微调整一下字段顺序都要重新核对坐标。第二种是JasperReports。它有自己的报表模板格式功能确实强学习成本也高。设计师用JasperSoft Studio画模板开发人员又要学一套JRXML语法如果你只是生成一份简单的订单确认书杀鸡用牛刀。第三种是Puppeteer/Playwright这类无头浏览器打印PDF。这个方案渲染效果最接近网页但它需要额外启动一个浏览器服务资源占用大部署也麻烦对于纯后端项目有点重。第四种就是今天要讲的Thymeleaf渲染HTML再转PDF。HTML模板谁都会写样式调整全部在模板里完成数据填充交给Thymeleaf表达式后端只需要把TemplateEngine渲染结果交给PDF组件。轻量、直观、好维护特别适合中小型项目里的固定版式单据。1.2 Thymeleaf方案的适用边界这套方案也不是万能的它最适合的是版式相对固定、数据动态变化的场景。比如电商订单确认书头部是订单号和下单时间中间是商品明细表格底部是金额合计、收货地址。这种结构用HTML表格完全够用模板写一次能复用很久。还有对账单、结算单、报销单、体检报告模版都属于这一类。如果你的需求是那种超级复杂的可视化图表PDF或者动辄几百页的大数据报表Thymeleaf方案就开始吃力了。图表需要额外绘制成图片再塞进HTML分页性能也不够理想这时候还是考虑专业的报表工具更合适。所以选型前先判断需求复杂程度不然做到一半再换方案成本更高。2. 技术准备工程搭建与依赖选型2.1 为什么选OpenPDF Flying Saucer这套组合HTML转PDF的Java组件最知名的是Flying Saucer它是基于iText早期版本封装的XHTML渲染器。现在iText已经升级到iText 7但对HTML转换的支持反而收费了开源版功能受限。目前社区里最活跃的替代方案是OpenPDF它fork了iText 4.x并持续维护配合Flying Saucer的兼容版本正好组成一套完整免费的中文HTML转PDF方案。我实测下来这一对组合在SpringBoot项目里非常稳依赖都不用额外做转换。SpringBoot版本方面我用的是2.7.xThymeleaf版本是3.0.x。这套组合在SpringBoot 3.x上也能跑但注意JDK版本要跟上后面讲模板引擎注入的时候细说。2.2 Maven依赖配置在项目的pom.xml中引入两个核心依赖一个是SpringBoot的Thymeleaf启动器一个是Flying Saucer的PDF输出模块。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.xhtmlrenderer/groupId artifactIdflying-saucer-pdf/artifactId version9.1.22/version /dependencyspring-boot-starter-thymeleaf会自动把模板引擎装配到Spring容器里而flying-saucer-pdf这个包会传递依赖引入openpdf版本对应是1.3.x。两个包加完不冲突直接用。可能有朋友问要不要单独引入openpdf依赖其实不需要flying-saucer-pdf已经传依赖带上了你再手动引一个版本反而容易搞出冲突。这个坑我见过不少至少先不加跑起来缺啥再说。2.3 环境准备要点JDK建议用8或11这个项目最常用。如果你用SpringBoot 3.xJDK17起步Thymeleaf要用3.1.x依赖坐标一样不过老一点的中文字体处理代码会有些差异我们下面也会讲到适配方法。字体文件是另一个绕不开的问题。Windows开发机上直接用系统字体就行了比如黑体、宋体。生产Linux服务器上就得提前确认有没有中文字体没有的话就得把字体文件打包进项目里或者安装字体包。这个后面专门讲因为它是中文PDF能不能正常输出的命门。3. 模板编写Thymeleaf模板的细节与姿态3.1 创建一个基本功扎实的XHTML模板先说一个最重要的事Flying Saucer处理的是XHTML不是普通的宽松HTML5。它要求XML格式良好标签必须闭合、属性必须带引号br这类单标签要写成br/img要写成img ... /。Thymeleaf渲染结果本身不校验XML格式如果你模板里漏了闭合标签轻则样式错乱重则PDF生成直接抛异常。模板推荐放在src/main/resources/templates/pdf/order.html这样SpringBoot的Thymeleaf自动配置就能扫描到。下面是一个带基础样式的模板骨架!DOCTYPE html PUBLIC -//W3C//DTD XHTML 1.0 Strict//EN http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd html xmlnshttp://www.w3.org/1999/xhtml xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8/ title订单确认书/title style typetext/css page { size: A4; margin: 1.5cm 1cm 1.5cm 1cm; } body { font-family: SimHei, Noto Sans SC, sans-serif; font-size: 10pt; color: #333333; } .title { font-size: 20pt; font-weight: bold; text-align: center; margin-bottom: 8pt; } .info-table { width: 100%; margin-bottom: 10pt; } .info-table td { padding: 4pt 0; } .detail-table { width: 100%; border-collapse: collapse; margin-top: 6pt; } .detail-table th, .detail-table td { border: 0.5pt solid #999999; padding: 5pt; text-align: center; } .detail-table th { background-color: #f2f2f2; } .amount-row { text-align: right; font-weight: bold; margin-top: 8pt; } /style /head body div classtitle订单确认书/div table classinfo-table tr td订单编号span th:text${order.orderNo}/span/td td下单时间span th:text${#dates.format(order.createTime,yyyy-MM-dd HH:mm)}/span/td /tr tr td客户名称span th:text${order.customerName}/span/td td联系电话span th:text${order.phone}/span/td /tr /table table classdetail-table thead tr th stylewidth: 40%;商品名称/th th stylewidth: 20%;单价元/th th stylewidth: 10%;数量/th th stylewidth: 30%;小计元/th /tr /thead tbody tr th:eachitem : ${order.items} td th:text${item.name}/td td th:text${item.price}/td td th:text${item.quantity}/td td th:text${item.subtotal}/td /tr /tbody /table div classamount-row 合计金额span th:text${order.totalAmount}/span 元 /div /body /html注意几个细节page控制页面尺寸和页边距这套CSS规则对最终PDF的版式起决定性作用。表格用border-collapse: collapse再配合border: 0.5pt solid转出来的PDF表线才清晰干净。Flying Saucer支持CSS 2.1子集加上一点CSS 3但绝对不要用flex、grid这些现代布局会把版面渲染得乱七八糟。3.2 字体问题的前置处理中文PDF最大的拦路虎就是字体。Flying Saucer内部解析字体时如果找不到对应字体中文就变成一个个小方框要么干脆消失不见。原因在于它默认字体不包含CJK字符集。字体处理有两个层次的做法。本地调试阶段Windows上直接指定系统字体文件就行。比如在Java代码里这样注册ITextRenderer renderer new ITextRenderer(); ITextFontResolver resolver renderer.getFontResolver(); // 注意不同系统字体路径不同 resolver.addFont(C:/Windows/Fonts/simhei.ttf, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); resolver.addFont(C:/Windows/Fonts/simsun.ttc, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);这里有个大坑simsun.ttc是字体集合文件用addFont注册时经常遇到解析异常或者注册了但效果不变。最稳妥的方式还是用单独的真字体文件simhei.ttf或者换用OpenJDK环境自带的DejaVu字体再补一个Noto CJK。生产环境建议把开源字体打包进项目比如思源黑体的SourceHanSansSC-Regular.otf或者NotoSansCJKsc-Regular.ttf。我实际项目里用的是Noto Sans CJK SC它是Google和Adobe一起做的开源字体版权干净跨平台显示效果也稳定。字体放在src/main/resources/fonts/后在SpringBoot里不能直接用ClassPathResource.getFile()读取JAR包内的文件会报FileNotFoundException。要走临时文件复制的方式下面代码可以直接用private void loadFonts(ITextRenderer renderer) throws IOException { ITextFontResolver resolver renderer.getFontResolver(); ClassPathResource fontResource new ClassPathResource(fonts/NotoSansCJKsc-Regular.ttf); File tempFontFile File.createTempFile(noto, .ttf); try (InputStream in fontResource.getInputStream(); OutputStream out new FileOutputStream(tempFontFile)) { in.transferTo(out); } catch (IOException e) { throw new RuntimeException(字体文件加载失败, e); } finally { tempFontFile.deleteOnExit(); } resolver.addFont(tempFontFile.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.EMBEDDED); }这段代码在打JAR包的生产环境也能正常工作。字体注册必须在renderer.layout()之前执行确保解析HTML时就已经知道用哪个字体去匹配中文字符。3.3 Thymeleaf常用指令与表达式模板里最常用的几个指令我们再单独拉出来过一遍。变量输出用th:text比如span th:text${order.customerName}/span。这个指令会做HTML转义安全系数高除非你确定内容包含富文本才用th:utext。条件判断用th:if和th:unless。比如订单状态字段有值就显示没有就显示默认文案写成span th:if${order.remark ! null} th:text${order.remark}暂无备注/span。集合遍历用th:each语法是th:eachitem, stat : ${order.items}其中stat是状态变量可以用stat.index取序号。${item.subtotal}这类表达式里点号访问的是JavaBean的属性对应OrderItem类里的getSubtotal()方法。日期格式化在模板里也能直接做${#dates.format(order.createTime, yyyy-MM-dd HH:mm)}。需要提醒的是如果改用SpringTemplateEngine并注入ContextThymeleaf会自动解析这些表达式不需要额外配置。但如果你的项目有严格的多语言需求要使用th:message读取国际化资源就得改用Context的Locale参数并装配MessageSource。这个场景我们还用不到知道有这回事就行。3.4 CSS支持边界哪些能用哪些不能用我把Flying Saucer的CSS支持边界总结成一个避坑清单方便在做模板时对照。能用的是这些块级元素的margin、padding、border、background-color、text-align、font-size、font-weight、color、line-height、page-break-before、page-break-after、page-break-inside。还有表格相关的border-collapse、width、height。不能用的是这些display: flex、grid各种伪类选择器中的高级写法position: fixed、position: stickyborder-radius不一定渲染box-shadow、text-shadow这些装饰性CSS3属性。如果你想在PDF中呈现水印或页脚也不要试图用position: fixedFlying Saucer对fixed支持非常弱。我踩过几次坑之后总结了替代方案可以用表格模拟页眉页脚把页眉内容放到每页的第一行页脚内容放到每页的最后一行利用表格的重复机制实现。这种方式粗糙但有效适合简单的水印和页码场景。4. 核心实现从模板渲染到PDF输出完整链路4.1 创建PDF生成服务类现在开始写核心服务类。我把生成流程封装成了一个独立的PdfGenerateService这样Controller只负责接收请求具体的生成逻辑全部收敛在Service层。Service public class PdfGenerateService { private final TemplateEngine templateEngine; public PdfGenerateService(TemplateEngine templateEngine) { this.templateEngine templateEngine; } public byte[] generateOrderPdf(OrderDTO order, String templatePath) { Context context new Context(Locale.CHINA); context.setVariable(order, order); String html templateEngine.process(templatePath, context); return htmlToPdf(html); } private byte[] htmlToPdf(String html) { ByteArrayOutputStream outputStream new ByteArrayOutputStream(); try { ITextRenderer renderer new ITextRenderer(); loadFonts(renderer); renderer.setDocumentFromString(html); renderer.layout(); renderer.createPDF(outputStream); } catch (Exception e) { throw new RuntimeException(PDF生成失败, e); } return outputStream.toByteArray(); } private void loadFonts(ITextRenderer renderer) throws IOException { // 字体加载逻辑见上面的临时文件方式 } }核心流程就三步setDocumentFromString注入渲染后的HTMLlayout排版createPDF输出字节流。TemplateEngine在SpringBoot项目里可以直接注入它是单例的由Spring Boot自动配置创建。如果你用的是SpringBoot 2.x注入TemplateEngine即可如果切换SpringBoot 3.x注意Thymeleaf版本的坐标可能有变化但注入方式一致。4.2 动态数据组装模板里的${order}对应一个DTO对象我一般会建一个独立的OrderDTO专门服务于PDF模板不直接把JPA实体扔进去。这样做的好处是模板字段和实体结构解耦比如实体里存的是内部状态码DTO里可以放一个已经翻译好的状态文案。public class OrderDTO { private String orderNo; private Date createTime; private String customerName; private String phone; private ListOrderItemDTO items; private BigDecimal totalAmount; // getter/setter } public class OrderItemDTO { private String name; private BigDecimal price; private Integer quantity; private BigDecimal subtotal; // getter/setter }组装DTO的逻辑放在Service里Controller只管调用。填写数据有个细节金额类字段建议在DTO里提前转为格式化后的字符串比如new DecimalFormat(#,##0.00).format(totalAmount)。因为Flying Saucer渲染HTML时对数字精度不敏感直接用BigDecimal显示可能会带科学计数法或者多余小数提前格式化最省心。4.3 模板解析与渲染templateEngine.process(templatePath, context)是Thymeleaf渲染入口templatePath就是模板相对路径比如pdf/order不需要带.html后缀。这个环节有一个容易被新手忽略的点模板引擎在解析HTML时如果原来写的是span th:text${order.customerName}占位文字/span那么渲染结果是span张三/span。这意味着浏览器里调试好的HTML结构和最终PDF的HTML结构基本一致可以先用浏览器打开模板渲染后的HTML看排版效果再交给PDF组件排错效率高很多。如果需要带相对路径的图片资源可以在renderer.setDocumentFromString(html, baseUrl)的第二个参数传入基础路径。比如模板里的img src/static/logo.png/baseUrl传http://127.0.0.1:8080它就能拼出完整URL去加载图片。如果不传baseUrl相对路径图片会全部失效。4.4 Controller接口设计预览与下载Controller写起来就清爽了直接返回ResponseEntitybyte[]浏览器收到后会自动识别为PDF文件下载。RestController RequestMapping(/api/pdf) public class PdfController { private final PdfGenerateService pdfGenerateService; public PdfController(PdfGenerateService pdfGenerateService) { this.pdfGenerateService pdfGenerateService; } GetMapping(/order/{orderId}) public ResponseEntitybyte[] downloadOrderPdf(PathVariable Long orderId) throws Exception { OrderDTO order buildOrder(orderId); byte[] pdfBytes pdfGenerateService.generateOrderPdf(order, pdf/order); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); // 中文文件名处理 String filename URLEncoder.encode(订单确认书- order.getOrderNo() .pdf, UTF-8); headers.setContentDispositionFormData(attachment, filename); headers.setContentLength(pdfBytes.length); return new ResponseEntity(pdfBytes, headers, HttpStatus.OK); } }中文文件名这里必须做URL编码不然浏览器下载时会出现乱码或者直接被拦截。注意setContentDispositionFormData在Spring 4.x之后还能用老项目放心用。4.5 批量导出与定时任务场景PDF生成不只用在在线下载很多业务是后台定时批量生成比如月末给客户统一发对账单。这种场景把返回byte[]改为直接写文件到本地目录然后用额线程池跑就行。public void generateToFile(OrderDTO order, String templatePath, String outputPath) { byte[] pdf generateOrderPdf(order, templatePath); try (FileOutputStream fos new FileOutputStream(outputPath)) { fos.write(pdf); } catch (IOException e) { throw new RuntimeException(写入PDF文件失败, e); } }批量场景要注意字体文件复用。如果每次生成都创建一次临时字体文件几百个任务同时跑临时文件会堆积。解决办法是把字体文件加载逻辑做成静态缓存或者用ITextFontResolver复用同一个渲染器实例并加锁。简单项目直接每次创建临时文件再用deleteOnExit兜底即可量大时再优化。5. 常见问题与排查技巧实录5.1 PDF中文变成方块或消失这是个高频问题几乎每个刚上手的朋友都会遇到。排查思路顺着这条线走字体有没有注册-字体路径是否正确-CSS字体族名是否匹配。先确认代码里resolver.addFont(...)有没有被调用。再确认路径指向的是一个可读的真字体文件。最后确认模板里font-family用到的字体名和注册字体是否一致。如果注册的是Noto Sans CJK SCCSS里写成font-family: Arial匹配不上照样会乱码。还有一个容易踩的坑是打包成JAR后ClassPathResource.getFile()报错。这就是上面说的字体文件读取方式问题切记改用临时文件方式。5.2 样式不对边框丢失、对齐错乱先检查模板是不是XHTML规范DOCTYPE有没有设置标签闭合是否完整。Flying Saucer对文档类型很敏感如果识别成宽松HTML模式CSS解析会有各种奇怪表现。再检查CSS里是否用了不支持的属性例如border-radius渲染不出来是正常的别跟它较劲。我遇到过最坑的一次是表格边框时有时无最后发现是border属性写在了table上而不是每个td上。Flying Saucer对表格边框的继承逻辑比浏览器严格border-collapse: collapse加在table上单元格必须逐一也声明边框才会显示完整。建议写成醒目的规范每个th、td都直接加border: 0.5pt solid #999;。5.3 模板路径找不到或渲染抛异常SpringBoot的Thymeleaf默认解析路径是classpath:/templates/。如果你的模板放在templates/pdf/order.html那么传给process的路径就是pdf/order不要带/前缀也不要带.html后缀。如果渲染时报错“找不到模板”就去看一下有没有把模板误放到src/main/resources/static/下。这个位置SpringBoot只做静态资源处理模板引擎扫描不到。还有一类异常是org.xml.sax.SAXParseException说明模板生成的HTML有XML语法错误。用浏览器打开渲染后的HTML按F12看控制台是查不出XML问题的要用在线XML校验工具或者Java的DocumentBuilder解析一下HTML字符串定位到具体行数。5.4 常见问题速查表现象可能原因解决方案中文全部变方块字体未注册或CSS字体族不匹配注册OpenType/TrueType中文字体CSS对应字体名图片不显示图片路径是相对路径缺少baseUrlsetDocumentFromString(html, baseUrl)传入基础地址表格边框丢失border写在table而不是td上每个th/td单独声明border样式页面空白区域过大page边距设置过宽调小page里的margin值渲染出来是乱码HTML未声明UTF-8或模板读取编码不对head里增加meta charsetUTF-8/服务器上模板找不到模板文件没有打包进JAR检查mvn package产物里是否包含templates目录PDF生成特别慢每次生成都重载字体文件把字体文件加载缓存到静态变量或复用渲染器池5.5 一个实际排错案例有一回做对账功能本地Windows下生成PDF一切正常部署到Linux服务器后中文全变方块。刚开始以为是字体没安装我ssh上去执行fc-list查了一下中文字体明明存在。后来查代码发现字体加载方法里用的是new File(C:/Windows/Fonts/simhei.ttf)这个写死的路径。Windows开发机上当然没问题Linux上根本找不到这个文件而且连异常都没有因为addFont对不存在的路径不一定抛错它只是默默忽略最终渲染时就用默认字体中文自然全变方块。教训就是字体加载不应该硬编码操作系统路径要么走ClassPathResource打包进JAR要么用System.getProperty判断系统类型再选择字体路径。现在我的项目里都改成资源打包方式换服务器再也不用愁。5.6 性能优化多开线程与渲染器复用PDF生成是CPU密集任务I/O不算多。如果一个请求一次只导出一份PDF整体压力不大。但批量导出几百份时单线程一个个跑很慢。我目前的做法是使用ThreadPoolTaskExecutor配置核心线程数等于CPU核数让PDF生成任务并行执行。ITextRenderer不是线程安全的不能多个线程共享同一个实例所以在任务方法内部每次创建新实例字体加载做成静态缓存这样既避免线程问题又省去了重复创建临时文件的开销。模板渲染也可以开Thymeleaf的缓存生产环境建议打开。SpringBoot里设置spring: thymeleaf: cache: true这个开关默认是开着的但如果你开发时手动关过记得部署前改回来。5.7 后续功能扩展思路已经跑通这套流程之后可以做的事情就多了。比如在模板里增加公司Logo、客户签名区域、二维码生成的效果和网页几乎一样只需在HTML里调样式。另外把工作流引擎接进来时PDF可以作为审批附件存储直接用文件服务上传再把下载链接拼到邮件或短信里。还可以用SpringBatch批量生成月度账单一次性生成整个客户群的PDF然后压缩为zip包提供下载。这套方案的扩展点在模板层只要你愿意写HTML业务形态就能做出很多花来不用再被PDF生成库的API绑住手脚。最后再分享一个小技巧模板开发阶段建议一边用浏览器直接打开模板渲染后的HTML样式一边生成PDF对照。因为Flying Saucer的渲染和Chrome全兼容还有不少差距最快的调试路径就是先在HTML层面把布局调对再逐个排除CSS兼容点而不是拿着PDF到浏览器里瞎猜。我用这个流程调模板效率至少翻了一倍。