ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot + Thymeleaf + Flying Saucer 实现PDF导出:中文乱码与CSS适配实战

Spring Boot + Thymeleaf + Flying Saucer 实现PDF导出:中文乱码与CSS适配实战 要说后端生成PDF这事儿十个项目里七八个最后都会走到同一条路先用模板把HTML拼出来再找个工具把HTML转成PDF。年前我做的一个对账单下载功能就是典型的Spring Boot Thymeleaf组合。需求本身不复杂难的是把整个链路跑顺、样式不乱、中文不花、批处理不崩。今天这篇不聊官方文档里已有的Hello World我把从方案选型、模板设计、字体处理到线上排障的整套经验拆开讲给正在做报表、合同、发票下载功能的朋友一个可以直接照着落地的参考。如果你正在纠结“为什么不用前端打印”“为什么不用iText直接画”或者已经在集成Fllying Saucer时被中文乱码、CSS不生效折腾得头疼这篇文章就是给你准备的。1. 为什么选择Thymeleaf生成PDF而非其他方案1.1 常见PDF生成方案横评后端生成PDF主流路子其实就那几种各有各的适用场景我先做个横向对比。第一种是iText直接编程式绘制。优点是功能强大、可控性极高几乎能实现PDF规范里的所有能力比如数字签名、表单域、权限控制。缺点是代码量大一个稍微复杂点的表格要写几十行甚至上百行Java代码业务调整样式的时候改起来非常痛苦相当于把页面设计的活硬搬到了Java代码里。第二种是用无头浏览器抓取页面打印比如Chromium Playwright/Puppeteer。优点是前端有什么样式PDF就是什么样CSS3、flex、grid全支持视觉还原度最高。缺点是重每个请求可能都要拉起一个浏览器进程并发高了内存扛不住而且渲染依赖前端页面稳定性一旦页面有接口慢或者资源加载失败PDF就跟着出问题。第三种就是Thymeleaf Flying Saucer也叫xhtmlrenderer的组合。先把模板引擎渲染成HTML字符串再用Flying Saucer把HTML解析成PDF。优势非常明显数据填充用模板语法模板就是普通HTML前端同事也能快速上手调样式生成的PDF文件体积小依赖轻不需要额外安装任何本地软件布局在服务端完成天然适合批量生成合同、对账单、报表这类结构固定、样式统一的文档。缺点是对CSS支持有限这个后面细说但绝大多数业务场景是够用的。1.2 Thymeleaf方案的核心竞争力很多人会问生成PDF为什么非得用Thymeleaf直接用String拼接HTML不就行了这里面的关键是“模板引擎”这四个字。String拼接最致命的问题有两个一是转义和引号混乱变量一多代码就成了一坨没法维护的字符串垃圾二是数据和视图耦合业务改字段、改样式都动Java代码谈不上团队协作。Thymeleaf的好处是模板文件和业务代码分离数据和模板通过Context绑定支持条件判断、循环、内联表达式甚至还能复用片段。简单说你的HTML模板就是一个完整的设计稿Java代码只负责把数据塞进去。还有一个实际场景是团队配合。我们项目里前端同事完全不懂Java但让他调Thymeleaf模板里的样式半小时就能上手。这比任何“用代码画PDF”的方案都更接地气。1.3 技术栈的职责划分搞清楚这条链路里每个组件的分工排查问题会顺畅很多。Thymeleaf干的是“渲染”的活接收Java对象作为Context数据根据模板语法生成一段完整的、带样式的HTML字符串。Flying Saucer干的是“解析”的活把这段HTML字符串按CSS规则排版输出成PDF字节流。最后你的Spring Boot控制器负责设置响应头把字节流写给浏览器触发下载。这三者之间真正容易出现断层的地方有两个一个是Thymeleaf渲染出来的HTML可能不符合XHTML规范导致Flying Saucer解析报错另一个是字体缺失导致中文变成乱码。这两个问题我后面都会有具体的解决方案。2. 集成前必须知道的三件事2.1 版本与依赖怎么选先贴一份我自己验证过可用的依赖组合基于Spring Boot 2.7.x。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.xhtmlrenderer/groupId artifactIdflying-saucer-pdf-openpdf/artifactId version9.1.20/version /dependency有个细节值得注意flying-saucer-pdf 和 flying-saucer-pdf-openpdf 是两条线。前者依赖的是老版com.lowagie:itext:2.1.7后者依赖的是OpenPDF这个维护更活跃的分支。从实际兼容性来说OpenPDF对Java 8的配合更友好字体处理路径也稳定一些我更推荐用带openpdf后缀这个版本。另外这个依赖会传递引入一些老版本的库比如org.apache.commons:commons-lang3注意和你项目里已有的版本冲突。遇到过供应商SDK引入的commons-lang3是3.4而Flying Saucer传递依赖需要3.5以上的情况最后是通过调整依赖版本区间解决的。2.2 模板不是用来“返回视图”的这里有个特别容易绕晕的概念问题。很多人第一次做会很自然地写一个Controllerreturn pdfTemplate以为Thymeleaf会解析模板然后交给某个PDF组件处理。这个思路在Spring MVC里其实是行不通的因为你返回给DispatcherServlet的是视图名不是HTML字符串更不是PDF文件。正确流程是反过来的注入一个SpringTemplateEngine直接调用它的process方法传入模板名和Context拿到String类型的HTML再把这个字符串交给ITextRenderer去生成PDF。也就是说Controller返回的根本不需要是视图名而是直接向HttpServletResponse的OutputStream里写文件流。打个比方Thymeleaf在这里不是“页面渲染器”而是“字符串工厂”。我一开始就是没转过这个弯一直在Servlet视图解析上折腾浪费了整整半天时间。2.3 中文字体是第一个绕不过去的坎Flying Saucer底层用的是iText的字体解析它对字体非常挑剔。如果你不注册任何中文字体默认字体里没有汉字生成的PDF里所有中文都会变成黑方块或者干脆消失。解决思路是在ITextRenderer里通过FontResolver手动注册一个支持中文的字体文件TTF或TTC格式都可以。字体文件可以放到resources/fonts目录下打成jar包后也能正常读取。ITextFontResolver resolver renderer.getFontResolver(); resolver.addFont( getClass().getResource(/fonts/simsun.ttf).toExternalForm(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED );这里两个参数值得解释一下。BaseFont.IDENTITY_H代表使用水平书写模式并且按Unicode编码映射字形这样才能正确匹配中文汉字BaseFont.NOT_EMBEDDED表示不把字体文件嵌入到PDF里生成的文件体积会小很多但代价是阅读方电脑上如果没有该字体就可能显示为替代字体。如果对文档便携性要求高可以改成BaseFont.EMBEDDED体积会大个几MB但到处都能显示原样。实际部署到Linux服务器之后也很容易踩“本地开发好好的线上中文就乱码”的坑。原因往往是Windows里宋体在C:\Windows\Fonts\simsun.ttc而Linux服务器上根本没有这个文件或者路径不对。稳妥的做法是用打包进jar包的字体而不是用操作系统的字体路径保证所有环境下都能找到。3. 从零搭建一个可用的PDF下载接口3.1 目录结构和基础依赖假设我们要做一个最简单的对账单PDF下载功能先看下项目结构。src/main/resources/ ├── templates/ │ └── statement.html └── fonts/ └── simsun.ttf非常清爽。模板就放在原来的Thymeleaf模板目录里字体文件单独放一个fonts目录。依赖方面就是2.1节说的那两个不需要额外加什么。Spring Boot版本我这边用的是2.7.18老一点的新一点的都没问题只要别把Spring Boot升到3.x然后还坚持用javax那一套接口全变了会额外麻烦。3.2 设计一份适合打印的Thymeleaf模板PDF模板设计和Web页面设计思路差异很大。最重要的一条铁律不要使用flex、grid、position:absolute这些Flying Saucer支持不到位的CSS3属性老老实实用table布局和margin、padding搞定一切。我来写一份简单的对账单模板。!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8/ style body { font-family: SimSun, 宋体, sans-serif; font-size: 12px; color: #333; margin: 0; padding: 0; } .title { font-size: 22px; font-weight: bold; text-align: center; margin-bottom: 20px; } .info-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; } .info-table td { border: 1px solid #ddd; padding: 8px; } .data-table { width: 100%; border-collapse: collapse; } .data-table th, .data-table td { border: 1px solid #999; padding: 6px 8px; text-align: center; } .data-table th { background-color: #f2f2f2; font-weight: bold; } /style /head body div classtitle对账单/div table classinfo-table tr td width20%客户名称/td td th:text${customerName}张三/td td width20%账单周期/td td th:text${billingPeriod}2024年6月/td /tr tr td账单编号/td td th:text${statementNo}ST202406001/td td生成日期/td td th:text${generateDate}2024-07-01/td /tr /table table classdata-table thead tr th序号/th th交易日期/th th交易类型/th th摘要/th th金额元/th /tr /thead tbody tr th:eachitem, iter : ${items} td th:text${iter.count}1/td td th:text${item.tradeDate}2024-06-01/td td th:text${item.type}消费/td td th:text${item.description}便利店购物/td td styletext-align:right; th:text${#numbers.formatDecimal(item.amount, 1, 2)}100.00/td /tr /tbody tfoot tr td colspan4 styletext-align:right; font-weight:bold;合计/td td th:text${totalAmount} styletext-align:right; font-weight:bold;100.00/td /tr /tfoot /table /body /html模板里我刻意做了几个设计都是为了适配Flying Saucer的特性。比如所有边框颜色都用了十六进制背景色用了常见的#f2f2f2这些是它完全能识别的。表头单独加粗加底色能提升PDF的打印效果。金额格式化直接在模板里用#numbers工具类处理这样Java代码里就不用先格式化好再传进来了。3.3 渲染HTML字符串与生成PDF接下来就是核心的PDF生成工具方法。Component public class PdfGenerator { private final SpringTemplateEngine templateEngine; public PdfGenerator() { ClassLoaderTemplateResolver resolver new ClassLoaderTemplateResolver(); resolver.setPrefix(classpath:/templates/); resolver.setSuffix(.html); resolver.setCharacterEncoding(UTF-8); resolver.setOrder(1); this.templateEngine new SpringTemplateEngine(); this.templateEngine.setTemplateResolver(resolver); } public byte[] generatePdf(String templateName, MapString, Object data) { Context context new Context(); context.setVariables(data); String html templateEngine.process(templateName, context); ByteArrayOutputStream baos new ByteArrayOutputStream(); try { ITextRenderer renderer new ITextRenderer(); ITextFontResolver resolver renderer.getFontResolver(); resolver.addFont( getClass().getResource(/fonts/simsun.ttf).toExternalForm(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED ); renderer.setDocumentFromString(html); renderer.layout(); renderer.createPDF(baos); return baos.toByteArray(); } catch (Exception e) { throw new RuntimeException(PDF生成失败, e); } } }这里用ClassLoaderTemplateResolver指定模板前缀和后缀最终模板文件放在classpath:/templates/statement.html。CharacterEncoding必须设置成UTF-8避免Thymeleaf渲染的时候用平台默认编码解析模板文件这个坑我排查过很久模板里明明写的是中文渲染出来的字符串却全是乱码就是因为没设置这个属性。ITextRenderer的layout方法也需要注意。它负责解析HTML里的CSS并计算元素位置必须在createPDF之前调用。有人图省事不调用layout生成的PDF会奇奇怪怪内容错位、缺行而且没有任何报错属于那种最难定位的隐性Bug。3.4 编写Controller和下载响应Controller层真正的逻辑很简单把业务数据组装成Map调用generatePdf再把字节流写给Servlet输出流。RestController RequestMapping(/api/pdf) public class StatementController { private final PdfGenerator pdfGenerator; public StatementController(PdfGenerator pdfGenerator) { this.pdfGenerator pdfGenerator; } GetMapping(/statement) public void downloadStatement(HttpServletResponse response) throws IOException { MapString, Object data new HashMap(); data.put(customerName, 某科技公司); data.put(billingPeriod, 2024年6月); data.put(statementNo, ST202406001); data.put(generateDate, 2024-07-01); data.put(items, Arrays.asList( new HashMapString, Object() {{ put(tradeDate, 2024-06-03); put(type, 消费); put(description, 办公用品采购); put(amount, 1260.50); }}, // 更多订单数据... )); data.put(totalAmount, 1260.50); byte[] pdfBytes pdfGenerator.generatePdf(statement, data); response.setContentType(application/pdf); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(对账单-S202406001.pdf, UTF-8)); response.getOutputStream().write(pdfBytes); response.getOutputStream().flush(); } }这里有个小细节下载文件名如果是中文直接用response.setHeader塞进去浏览器端基本都会乱码。必须要先URLEncoder.encode才能正确显示。如果希望浏览器直接预览而不是下载把attachment改成inline即可。提示如果用inline方式预览测试的时候尽量用Chrome或者Edge这两个浏览器对PDF预览内置支持最好Safari偶尔会出现显示异常但不影响实际文件内容。4. 进阶动态数据、图片与分页处理4.1 通过th:each渲染循环列表模板里的循环列表在哪个方案里都是高频场景。对账单、合同明细、商品清单全是列表。3.2节的模板里已经用了th:each的写法我再展开说说它和Excel导出那种“动态拼接行”的差别。Thymeleaf的th:each本质是在服务器端循环生成HTML片段你可以把它理解成一个在Java里生成字符串的for循环但是以模板声明的方式来写。普通for循环生成HTML你得处理每一行的换行、缩进、标签闭合循环嵌套一多字符串拼接很容易出错。而th:each把这些逻辑全部收敛到模板里每次迭代自动复用标签结构。如果需要在循环里取到当前迭代序号写法就是模板里的th:text${iter.count}它是1起始的索引直接作为表格序号字段非常方便。如果要做奇偶行变色可以用${iter.odd}来判断这是动态报表常见的美观处理。4.2 图片如何无缝嵌入PDF里经常需要放Logo、签名图这里有几个实用技巧。第一种是Base64字符串直接嵌入适合服务端已经拿到图片字节流的情况。模板里可以这样写img th:srcdata:image/png;base64, ${logoBase64} stylewidth:80px;height:80px;/Java侧把图片文件读进来转成Base64字符串塞进Map即可。这种方式生成的PDF不依赖外部路径文件是自包含的迁移、发邮件、归档都非常稳妥。第二种是使用本地文件路径但在Linux服务器上要格外小心路径问题。Flying Saucer解析图片路径时如果遇到相对路径或者file://协议很容易出问题。建议统一转换为绝对路径的file:///访问。顺带提醒一个点Flying Saucer对图片格式的支持也有局限PNG、JPG这俩常规格式没问题WebP、SVG基本是废的不要试图在这个方案里用SVG。我之前在模板里嵌了个SVG图标折腾了半天还是黑块最后换PNG解决。4.3 分页与页面边距控制长表格内容超出一页之后会自动拆页但拆的位置很随机有可能把一行记录劈成两半上半页一行下半页又一行的“半行”效果是最不专业的。解决这个问题靠CSS的page-break属性。.data-table tr { page-break-inside: avoid; } .title { page-break-after: avoid; } .table-section { page-break-before: always; }page-break-inside: avoid意思是这一行尽量不要被拆分如果空间不够就把整行挪到下一页。page-break-before: always用于强制分页比如对账单里每个客户要单独起一页就在区块外层div加这个属性。Flying Saucer对这两个属性支持得还不错是PDF分页控制的利器。页面边距的控制可以在模板的style里定义page规则page { size: A4; margin: 2cm 1.5cm 2cm 1.5cm; }如果要做更复杂的页眉页脚每页都显示“第X页 共Y页”那Flying Saucer原生能力就比较弱了。常规做法是在循环数据里手动在每个区块底部加一行页脚信息但这样每页的页脚位置不是真正固定在页面底部而是跟随内容流。追求完美固定页脚的话得自己扩展PdfPageEvent但这个方案复杂度会明显上升。我的建议是业务上不是强需求就别做PDF打印场景用户更关注内容完整性而不是页脚像素级对齐。5. 线上踩坑实录与排查速查表5.1 高频问题与解决思路我把实际项目里遇到频率最高、搜索量最大的几个问题汇总成一张表方便大家保存。问题现象根因解决方案PDF中文全是黑块或消失没有注册支持中文的字体ITextFontResolver.addFont加入中文字体如simsun.ttf本机正常Linux服务器乱码jar包里没有字体依赖使用classpath下的字体文件不要依赖系统路径Thymeleaf表达式原样输出渲染HTML字符串时遗漏Context确认调用的是templateEngine.process且正确传入ContextCSS3样式全部失效Flying Saucer仅支持CSS 2.1子集改用table布局避免flex/grid使用内联样式兜底下载的文件名乱码Content-Disposition未编码URLEncoder.encode(filename, UTF-8)模板中文在HTML源码里乱码模板解析编码错误ClassLoaderTemplateResolver设置characterEncoding为UTF-8生成的PDF打不开字节流写入不完整检查response.flush和OutputStream是否关闭图片显示为叉号图片路径或格式不支持使用Base64内嵌改用PNG/JPG关闭缓存后PDF内容重复反序列化对象引用问题每笔订单数据单独构建Map避免共享引用生成大量PDF时内存溢出每一个PDF都持有字体/image缓存将ByteArrayOutputStream拆成多个批次处理使用后及时置空这里的每一项都是我之前踩过的真实坑。尤其是“关闭缓存后PDF内容重复”那条当时是用了同一个对象实例填充多页数据结果Flying Saucer渲染时因为对象id相同多个条目引用了同一份数据导致所有行内容都一样。后来新开Map存放每条数据才解决。5.2 排查异常的小技巧如果你想快速定位是模板渲染的问题还是PDF转换的问题强烈建议用一个小技巧先把渲染好的HTML字符串写到一个临时文件里用浏览器直接打开此文件看效果。Files.write(Paths.get(/tmp/statement.html), html.getBytes(StandardCharsets.UTF_8));打开这个文件后分两种情况判断。如果浏览器里的HTML显示正常说明Thymeleaf渲染没问题问题出在Flying Saucer对CSS或者字体的支持上。如果浏览器里就是乱的那肯定是模板代码写错了直接改模板就行根本不需要惊动PDF组件。这个办法能帮你省掉大量猜测时间是我在实际开发中屡试不爽的套路。还有一个debug思路是打开Flying Saucer的日志。ITextRenderer里有个setLogger接口可以自定义Logger实现来输出详细的解析警告。很多CSS属性被忽略时它会打出类似“Property display: flex is not supported”之类的信息。看到这类日志直接去模板里换写法就行不要硬刚属性支持问题。6. 我个人最后想补充的几个习惯做这个功能快两年了回过头来看最影响维护效率的其实不是某个技术难点而是模板文件和生成代码的风格规范。我的建议是模板里的样式量力而行能用table就用table少用花哨的CSS3特性。不是说Flying Saucer不支持的就一定不好而是你要清楚自己的目标是输出一份稳定、不崩、打印不错的PDF又不是做官网首页。样式写得越花踩坑概率越高生成效率也越低。颜色的使用也建议克制。PDF打印大多是黑白场景深色背景会大量消耗墨粉而且很多打印机对浅色背景的渲染效果不佳。我的模板里全部用白底黑字、#f2f2f2这种极浅的背景做表头分割就够了。字体统一管理这件事也值得多说一句。不要每个模板里都写不同的font-family更不要在Java侧反复注册多个字体文件。字体注册是个比较重的操作每个PDF生成实例都要执行注册路径越多性能损耗越明显。我们团队最后统一成一套“模板规范”所有PDF模板固定使用一个中文字体文件避免了这个问题的再次发生。最后再给一个建议生成PDF的方法尽量做成一个无状态的工具类不要持有ITextRenderer或者SpringTemplateEngine之外的重量级状态。这样在高并发场景下每个请求走完整个渲染生命周期用完即释放不会出现内存里堆积大量中间对象的隐患。我就是把PdfGenerator设计成Spring单例Bean但Bean内部只有无状态的TemplateEngine以及单纯的byte[]处理逻辑实测并发下非常稳。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进