Freemarker+HTML生成PDF:模板渲染、转换器选型与批量打包避坑指南
简介面向Java开发者的PDF生成实战资源聚焦Freemarker模板引擎与HTML转PDF的完整链路适用于报表、发票、文档自动化等常见场景适合具备Java基础、希望快速实现文档输出的开发者。压缩包共17个文件以8个Java源码和4个HTML模板为核心另含TTF字体、XML配置、README说明文档、Maven工程文件及LICENSE等整体仅5.12MB结构清晰便于检索。已有775人学习下载。示例项目演示了从设计HTML模板、配置Freemarker、构建数据模型、处理模板到调用Flying Saucer等工具渲染PDF的完整流程读者可参考源码理解模板与数据绑定的核心逻辑并借助示例数据快速验证生成效果同时HTML模板与字体文件可直接复用帮助缩短开发周期。项目基于Maven构建导入IDE即可运行适合用作文档生成功能的基础骨架或学习模板。1. 为什么freemarkerhtml生成pdf这条链路值得你照着搭一遍做后端时间稍长就会接到一类共性需求对账单、合同、体检报告、订单明细数据都存在数据库里格式却要跟印刷品一样整齐。如果直接用 iText 手写 PDF一半工时都会耗在调坐标上换成 JasperReports又要被它的设计器和复杂 XML 结构绑住手脚。而freemarkerhtml生成pdf这套组合的聪明之处在于Freemarker 只负责往 HTML 模板里灌数据HTML/CSS 负责排版转换器负责出 PDF最后打成 zip 包一次交付给前端下载。这条链路适合独立负责报表导出模块、又不想引入重型报表引擎的 Java 后端。真正值钱的部分不在 Freemarker 本身而在 html 转 pdf 那一层——中文字体、CSS 兼容性、分页行为每一个坑都能让你磨掉一整天。2. 先理解渲染链路freemarker 生成 html 只是前半场2.1 为什么是 freemarker html而不是直接写 PDF我最早做导出功能时用纯 iText 方案写一个带表格和页眉的对账单要几十行定位代码业务字段一加就得重新调坐标。后来换 JasperReports又被它的 JRXML 设计器折腾得不轻。转回 freemarker html 路线后模板交给懂 CSS 的人也能改业务上要加一列改的是 HTML 表格结构不用碰 Java 代码。这是这条链路最核心的价值把 PDF 排版复杂度转化为 Web 页面排版复杂度而后者的工具链和人才储备都成熟得多。这条链路的本质是分层。Freemarker 是模板引擎负责把 Map 或 JavaBean 数据填充进模板文件输出结果是完整 HTML 字符串PDF 是最终交付物由转换器从 HTML 渲染而来。所以freemarkerhtml生成pdf不是某个库干到底而是一条三级流水线数据模型进 FreemarkerHTML 字符串进转换器PDF 字节进 zip 包。选择理由很实际——HTML 的排版能力比任何 PDF 库都强表格、浮动、分页、页眉页脚都有成熟的 CSS 语法可以表达前端同学可以直接参与模板维护。反过来说引入这条链路也有代价。中间多了一道 HTML 到 PDF 的转换转换器的 CSS 解析能力直接决定最终效果。我在落地时坚持把先跑通最小模板作为第一步而不是直接写业务模板就是为了把排版问题控制在最小范围内定位。如果你抱着HTML 在浏览器什么样PDF 就应该什么样的预期来做大概率会撞上 CSS 兼容性的墙。转换器不是浏览器它连 CSS3 都未必认全。还有一个更不该走的弯路HTML 转图片再贴进 PDF。听起来能复用浏览器渲染能力但图片分辨率固定放大就糊而且文字不可选中、不可检索做给客户存档完全不合格。宁可花时间调转换器参数也不要走这条伪捷径。2.2 完整数据流从数据模型到 zip 包整条链路的走法如下数据模型Map 或 POJO→ FreeMarker Template.process() → HTML 字符串 → PDF 转换器wkhtmltopdf / Flying Saucer / OpenPDF→ PDF 字节流 → ZipOutputStream 批量打包 → 前端下载 .zip这里的关键认知是Freemarker 不产生 PDF它只负责把数据渲染进 HTML。标题里的freemarkerhtml生成pdf实际含义是用 Freemarker 渲染 HTML再用 HTML 驱动出 PDF。zip 是最后一步分发手段多张 PDF 文件塞进一个压缩包避免前端逐个下载。我在实际项目里的习惯是把这段链路拆成四个独立方法renderHtml()、htmlToPdf()、batchGenerate()、zipAndDownload()。每一段都能单独测试。这样做的好处很直接当某张 PDF 渲染不对你能通过分段打出中间产物快速定位是模板问题、数据问题还是转换器参数问题。而不是面对一团黑盒日志从头猜起。2.3 用 Freemarker 渲染最小 HTML 模板先搭一个最简单的模板文件 template.html。注意 Freemarker 默认按 .ftl 后缀找模板但用 .html 后缀也可以只要给 Configuration 指定正确的模板加载方式。模板内容如下!DOCTYPE html html langzh-CN head meta charsetUTF-8/ title${title}/title style body { font-family: SimSun, serif; font-size: 12px; } table { width: 100%; border-collapse: collapse; } td, th { border: 1px solid #333; padding: 6px; } /style /head body h3${title}/h3 p客户${customerName}/p table trth序号/thth项目/thth金额/th/tr #list items as item trtd${item_index 1}/tdtd${item.name}/tdtd${item.amount}/td/tr /#list /table p合计${total}/p /body /html对应 Java 端渲染代码public String renderHtml(MapString, Object data, String templateName) throws Exception { Configuration cfg new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding(UTF-8); cfg.setDirectoryForTemplateLoading(new File(templates)); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); Template template cfg.getTemplate(templateName); StringWriter writer new StringWriter(); template.process(data, writer); return writer.toString(); }逻辑说明第 2 行的 Configuration 是 Freemarker 的入口对象VERSION 常量必须和 pom 里的依赖版本对应版本不匹配会抛 API 兼容异常第 3 行不设默认编码的话模板读取和输出两个环节都会乱码第 4 行指定模板目录也可以换成 ClassTemplateLoader 从 classpath 加载Spring Boot 项目更推荐后者部署时不用关心模板绝对路径第 6 行的 RETHROW_HANDLER 让模板报错直接抛异常本地调试时能看到具体行号而不是吞掉错误输出一个残缺 HTML。参数说明${title} 是插值语法从数据 Map 里取对应 key#list items as item 是 Freemarker 循环指令item_index 是内置序号变量从 0 开始total 这类金额字段建议在 Java 侧就格式化成字符串传进去不要在模板里做数字格式化否则不同报表的格式写法难以统一后期维护很头疼。这一步跑通后先把输出的 HTML 字符串保存成 .html 文件用浏览器打开看看排版确认无误再进转换器环节。这个习惯能排除一半的PDF 不对问题因为很多渲染差异其实是 HTML 阶段就错了到 PDF 阶段排查反而更难。2.4 Freemarker 模板的三条军规经验多了以后我总结出 Freemarker 模板在三层约束下最容易维护。第一条模板里只做展示逻辑不做业务计算。金额合计、日期格式化、状态文案翻译全部放到 Java 侧处理。模板里写${total?string(0.00)}虽然方便但同样的格式逻辑复制到五个模板里改格式时就要改五处。统一在 Java 侧格式化模板就只是一个纯展示层。第二条CSS 尽量内联或者集中在模板头部 style 标签中不要外链 .css 文件。转换器渲染 PDF 时外链 CSS 的加载路径和顺序经常出问题特别是 Flying Saucer 这类对相对路径敏感的引擎。内联样式虽然丑但它在任何转换器下行为一致。第三条可选字段用#if field??包起来而不是依赖空值默认渲染。比如备注可能为空不判断就会在 PDF 里输出一行备注null或者抛空值异常。Freemarker 对空值默认抛错除非配置了 default 值所以养成写??判断的习惯能少踩很多低级坑。3. html 转 pdf 这一步转换器选型与关键参数3.1 三种常用转换器怎么选HTML 转 PDF 的工具不少但落地最常用的就三类wkhtmltopdf、Flying Saucerxhtmlrenderer、OpenPDF 配合 XMLWorker。它们的差异集中在 CSS 支持度、运行依赖和部署方式上选型时先看自己的部署环境。wkhtmltopdf 是最省心的选择。它是独立进程基于 Qt WebKit 内核对 CSS 2.1 支持最完整表格、浮动、页眉页脚都能处理。代价是服务器上要多装一个系统级工具Java 代码里通过 ProcessBuilder 或命令行调用它。如果你的运维允许 apt/yum 装包选它没错。Flying Saucer 是纯 Java 方案能直接嵌入 Spring Boot 项目以 jar 方式运行适合内网环境和不想装额外软件的场景。但它要求输入是严格 XHTML标签必须闭合属性必须加引号CSS3 属性基本不支持border-radius、flex 这些在它眼里是废代码。模板要按它的规则约束着写。OpenPDF 搭配 XMLWorker 是 iText 2.x 的开源分支能解析 HTML 片段并输出 PDF但表格样式支持弱复杂布局容易错位。我在实际项目里只用它做简单的单页凭证业务报表基本不碰。如果你已经有 iText 经验可以用它过渡但别对 HTML 渲染效果抱有期待。还有个方向值得一提Chrome Headless 的 --print-to-pdf渲染效果和浏览器一模一样适合对 CSS 兼容性要求极高的场景。但每次启动浏览器进程开销大服务器要装 Chromium内存占用在批量任务里会放大。如果只是偶尔生成几份复杂报表可以考虑做高频批量导出时还是优先 wkhtmltopdf。3.2 wkhtmltopdf 的集成方式与常用参数先在操作系统层装好工具Debian/Ubuntu 用 apt install wkhtmltopdfCentOS 用 yum install wkhtmltopdf同时记得装中文字体包。没有中文字体生成的 PDF 里中文全是方框。Java 侧调用代码public byte[] htmlToPdfWithWk(String html, PdfOptions options) throws Exception { Path input Files.createTempFile(report, .html); Path output Files.createTempFile(report, .pdf); Files.write(input, html.getBytes(StandardCharsets.UTF_8)); ListString cmd new ArrayList(); cmd.add(wkhtmltopdf); cmd.add(--page-size); cmd.add(options.getPageSize()); cmd.add(--margin-top); cmd.add(options.getMarginTop()); cmd.add(--margin-bottom); cmd.add(15mm); cmd.add(--encoding); cmd.add(UTF-8); cmd.add(--enable-local-file-access); cmd.add(--footer-center); cmd.add(第 [page] 页 / 共 [topage] 页); cmd.add(--footer-font-size); cmd.add(9); cmd.add(input.toString()); cmd.add(output.toString()); Process process new ProcessBuilder(cmd).redirectErrorStream(true).start(); try (InputStream is process.getInputStream()) { byte[] log readAll(is); int code process.waitFor(); if (code ! 0) { throw new IllegalStateException(wkhtmltopdf exit code : new String(log, UTF_8)); } } byte[] pdf Files.readAllBytes(output); Files.deleteIfExists(input); Files.deleteIfExists(output); return pdf; }逻辑说明先把 HTML 字符串写成临时文件是因为 wkhtmltopdf 接受文件路径而不是标准输入--enable-local-file-access 允许 HTML 里引用本地图片和字体资源不加这个参数在较新版本中会拒绝读取本地文件。临时文件用完立刻删除防止批量任务堆积。这里最需要注意的是子进程输出流的处理。ProcessBuilder 的 redirectErrorStream(true) 把标准输出和标准错误合并必须先消费完这个流再 waitFor否则子进程写满管道缓冲区会阻塞挂起。代码里用 try-with-resources 包住 InputStream读取完再回收进程顺序不能反。参数说明--page-size 常用 A4也可以写 Letter 或 A3--margin-top/bottom/left/right 接受 mm 或 cm 单位页脚占位符 [page] 和 [topage] 是 wkhtmltopdf 内置变量会自动替换为当前页码和总页码--encoding UTF-8 配合 HTML 里 charsetUTF-8 声明一起使用缺一个就会中文乱码。注意 cmd.add 时每一项参数都要拆成独立的 list 元素--page-size 和 A4 是两个元素合成一个字符串 --page-size A4会被当成单个参数传给程序命令行解析直接失败。wkhtmltopdf 还有一个很有用的参数 --header-html可以传入一个独立的 HTML 模板文件作为每页页眉适合带公司 Logo 的正式单据。用法是先在本地写一个 header.html然后加两个参数--header-html header.html 和 --header-spacing 5。页眉模板里的样式同样要用内联写法外部引用的图片路径在转换时也要能被 wkhtmltopdf 进程访问到。3.3 Flying Saucer 的参数与 CSS 边界用 Flying Saucer 时Java 代码看起来更纯不依赖外部进程public byte[] htmlToPdfWithFlyingSaucer(String xhtml) throws Exception { ITextRenderer renderer new ITextRenderer(); renderer.setDocumentFromString(xhtml, file:///tmp/base/); renderer.layout(); try (ByteArrayOutputStream baos new ByteArrayOutputStream()) { renderer.createPDF(baos); return baos.toByteArray(); } }逻辑说明setDocumentFromString 的第二个参数是 baseURL用来解析 HTML 里相对路径的图片和 CSS。如果没有外部资源传一个不存在的基础目录也不会报错layout() 必须抢在 createPDF 之前调用它完成页面尺寸计算和分页布局createPDF 输出到 ByteArrayOutputStream 后直接返回字节数组。参数说明页面大小和边距要在 CSS 里控制Flying Saucer 认 page 规则比如page { size: A4; margin: 10mm 15mm; }写在模板的 style 标签中。它支持 page-break-before 和 page-break-inside 属性控制分页但不支持 position: fixed 做页眉页脚这是它和 wkhtmltopdf 最大的体验落差。表格边框、背景色这类基础样式它处理得很好但 flex、圆角、阴影、渐变一概不认模板设计时要刻意避开这些特性。字体注册是 Flying Saucer 方案绕不开的一步。它的底层是 iTextiText 默认只认识标准 14 种 PDF 字体不注册中文字体就无法显示汉字。注册代码要放在渲染之前而且提供一个同时覆盖 FontFactory 和 renderer 的方案FontFactory.register(/fonts/simsun.ttf, SimSun); renderer.getFontResolver().addFont(/fonts/simsun.ttf, BaseFont.IDENTITY_H, true);其中 BaseFont.IDENTITY_H 表示使用 Unicode 水平编码第三个参数 true 表示嵌入字体子集。嵌入后 PDF 在任何机器上显示一致代价是文件变大一点但中文字体本来就躲不开嵌入这一步。3.4 参数与效果对比三种方式的核心差异整理如下对比维度wkhtmltopdfFlying SaucerOpenPDF XMLWorker运行方式独立系统进程纯 Java 嵌入纯 Java 嵌入CSS 2.1 支持完整大部分弱CSS3 支持部分支持不支持不支持页眉页脚--footer/--header 参数不支持 position: fixed需手动绘制中文字体依赖系统字体需安装注册 FontFactory注册 FontFactory部署成本要安装系统工具打 jar 即可打 jar 即可放在实际项目里我的建议很直接如果服务器能装系统包优先 wkhtmltopdf如果必须零安装部署选 Flying Saucer但前端模板要按 XHTML 严格模式写。OpenPDF 只用来做应急兜底比如临时生成一页简单凭单。决策时还要考虑一个因素你的模板会不会频繁加 CSS3 特效。如果不会Flying Saucer 完全够用如果会就得为 wkhtmltopdf 的兼容性买单。4. 跑通完整流程从多张 PDF 到 zip 包下载4.1 工程依赖与目录结构先搭好工程基础。用 Spring Boot 的话pom.xml 里只需加 freemarker 依赖转换器按选型补充。以 wkhtmltopdf 路线为例dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependency dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.28/version /dependencyPDFBox 在这里有两个用途一个是第四章的自校验读取 PDF 文本另一个是偶尔需要合并 PDF 时它提供 PDFMergerUtility。虽然标题链路没提合并但导出多个 PDF 后常有合并成一份的需求提前把依赖装上省得后面再加。目录结构建议这样放src/main/resources/templates/pdf/ —— Freemarker 模板文件一个业务模板一个 .ftlsrc/main/java/.../pdf/ —— PdfGenerateService、ZipService、PdfOptions临时文件统一放 java.io.tmpdir 下的独立子目录批量生成完清空4.2 一个带样式的业务模板对账单示例写一个稍微贴近业务的模板用上 Freemarker 的条件和循环指令!DOCTYPE html html langzh-CN head meta charsetUTF-8/ title对账单-${billNo}/title style .bill-header { text-align: center; font-size: 18px; font-weight: bold; } .bill-info { margin: 10px 0; font-size: 13px; } .bill-table { width: 100%; border-collapse: collapse; font-size: 12px; } .bill-table th { background: #f0f0f0; } .bill-table td, .bill-table th { border: 1px solid #555; padding: 6px; } .total-row { font-weight: bold; } tr { page-break-inside: avoid; } /style /head body div classbill-header对账单/div div classbill-info单号${billNo} | 日期${billDate} | 客户${customerName}/div table classbill-table trth序号/thth项目/thth单价/thth数量/thth金额/th/tr #list items as it tr td${it?counter}/td td${it.name}/td td${it.price}/td td${it.quantity}/td td${it.amount}/td /tr /#list tr classtotal-row td colspan4 styletext-align:right;合计/td td${totalAmount}/td /tr #if remark?? trtd colspan5备注${remark}/td/tr /#if /table /body /html逻辑说明页面分块用语义化 CSS 类名方便转 PDF 后维持结构一致tr { page-break-inside: avoid; }这条在多数转换器下都能避免表格行被跨页截断#if remark?? 判断数据 Map 里存在 remark 键才渲染备注行这是处理可选字段的标准写法${it?counter} 是循环内置变量从 1 开始计数比 item_index 更贴合业务展示习惯。这里再强调一次金额字段不要在模板里做加法。totalAmount 应该在 Service 里用 BigDecimal 算好放进数据 Map模板只负责展示。这样金额计算的正确性可以用 JUnit 单测覆盖模板层只关心排版职责边界清晰。4.3 核心 Service批量生成 PDF 并打包 zip到了真正的链路整合。这个 Service 把前面的渲染和转换串起来处理多张单据批量导出public byte[] generateBatchZip(ListMapString, Object dataList) throws Exception { PdfGenerator generator new PdfGenerator(); String tmpDir Files.createTempDirectory(pdf_batch_).toString(); ListPath pdfFiles new ArrayList(); for (MapString, Object data : dataList) { String html renderHtml(data); byte[] pdfBytes htmlToPdfWithWk(html, buildOptions()); Path pdfPath Paths.get(tmpDir, bill_ data.get(billNo) .pdf); Files.write(pdfPath, pdfBytes); pdfFiles.add(pdfPath); } ByteArrayOutputStream zipBaos new ByteArrayOutputStream(); try (ZipOutputStream zos new ZipOutputStream(zipBaos)) { for (Path pdfPath : pdfFiles) { ZipEntry entry new ZipEntry(pdfPath.getFileName().toString()); zos.putNextEntry(entry); zos.write(Files.readAllBytes(pdfPath)); zos.closeEntry(); } } for (Path p : pdfFiles) { Files.deleteIfExists(p); } Files.deleteIfExists(Paths.get(tmpDir)); return zipBaos.toByteArray(); }逻辑说明Files.createTempDirectory 创建独立临时目录避免多个批量任务并发时文件互相覆盖逐条渲染并写临时 PDF 文件是为了给 wkhtmltopdf 提供明确的输入输出路径ZipOutputStream 写入时一次性读入内存对单文件几 MB 的场景没有问题如果单个 PDF 超过几十 MB建议改成 Files.newInputStream 流式写入 zip避免内存峰值过高。参数说明zip 内文件名用 ASCII 安全的 bill_{billNo}.pdf。Java 的 ZipOutputStream 默认用 UTF-8 编码文件名现代 Windows 10 的资源管理器能正常解压但老版本 WinRAR 或某些国产解压工具可能乱码。稳妥做法是文件名只用数字和短横线宁可少一点可读性换取跨平台兼容。4.4 下载接口与前端联调细节Service 返回 byte[] 后控制器层要把字节流交给前端。这段代码看起来简单但有两个注意点PostMapping(/export/batch) public ResponseEntitybyte[] exportBatch(RequestBody ListString billNos) { ListMapString, Object dataList billService.queryBillData(billNos); byte[] zipBytes pdfService.generateBatchZip(dataList); String fileName URLEncoder.encode(对账单_ System.currentTimeMillis() .zip, UTF-8); return ResponseEntity.ok() .header(Content-Disposition, attachment; filename*UTF-8 fileName) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(zipBytes); }逻辑说明Content-Disposition 用 filename*UTF-8 的格式而不是直接写 filename对账单.zip是为了避免老版本浏览器对中文文件名解析乱码URLEncoder.encode 把中文文件名变成百分号编码RFC 5987 规定的标准做法。前端如果走 AJAX 下载 zip要注意 responseType 必须设置成 blob否则拿回来的是字符串再转 blob 时编码已经坏了。常见做法是 axios 里写 responseType: arraybuffer拿到后用 new Blob([data]) 生成文件对象触发下载。这个坑不算 PDF 链路独有但 zip 文件是二进制编码一坏整个包都打不开且错误不容易发现。4.5 文件名的坑与流关闭顺序生成 zip 时最容易翻车的不是压缩逻辑而是流的关闭顺序。ZipOutputStream 必须在最外层 try-with-resources 中关闭因为关闭 zos 会 flush 中央目录尾部数据。漏掉这步生成的 zip 包用 unzip 命令测试可能正常但用 Windows 资源管理器打开会报压缩文件已损坏实测血泪教训。另外建议所有临时文件在 finally 块或 try-with-resources 里清理而不是生成完就丢。服务器上跑定时批量导出任务如果每次留两个临时文件一周下来 /tmp 目录就能堆出上千个小文件而且每个 wkhtmltopdf 子进程退出前都会往临时目录写文件进程异常终止时残留更多。inode 耗尽的故障排查起来很隐蔽最后才发现是 /tmp 被小文件塞满。5. 避坑手册字体乱码、分页断裂与 CSS 失效的排查记录5.1 中文全部变成方框或空白现象HTML 在浏览器打开完全正常转成 PDF 后中文变成一排□□□或者干脆整段空白。原因转换器进程找不到中文字体。wkhtmltopdf 是独立进程按系统字体表找字体服务器最小化安装通常没有中文字体Flying Saucer 则是没有向 FontFactory 注册中文字体文件。解决wkhtmltopdf 路线先在服务器执行 fc-list :langzh 看系统里有没有中文字体没有就安装 fonts-wqy-zenhei 或 fonts-noto-cjk同时给 wkhtmltopdf 加参数 --font-family WenQuanYi Zen Hei强制指定字体族。Flying Saucer 路线在渲染前注册字体用配置文件或启动时扫描方式加载字体文件。5.2 表格边框和背景色在 PDF 里消失现象HTML 表格有边框转出 PDF 后边框没了或只剩下部分边框表头背景色也丢了。原因转换器对 border 简写属性的解析不完整。border-collapse: collapse 加上 border: 1px solid #555 这种简写wkhtmltopdf 在某些版本下会丢边Flying Saucer 对外链 CSS 的加载顺序敏感样式文件加载失败时边框和背景色一并丢失但文字还在。解决把 PDF 转换涉及的关键样式写成内联样式或者至少把边框拆开写border-width、border-style、border-color 三项分开不写简写。背景色改用带 !important 的 class 规则并且确认模板里没有外链 CSS。我一般直接把模板样式全部内联省得排查加载顺序。5.3 wkhtmltopdf 在 Linux 上报 error while loading shared libraries现象代码在 Windows 上运行正常部署到 Linux 服务器后执行 wkhtmltopdf 直接报错错误里出现 libXrender.so.1 之类的字样。原因wkhtmltopdf 依赖 X11 相关的共享库服务器为了省资源通常没装 X 组件缺 libXrender、libXext、libfontconfig 都会导致启动失败。报错信息里的库名能直接定位缺什么。解决Debian 系执行 apt-get install -y libxrender1 xfonts-75dpi xfonts-base fontconfigCentOS 系执行 yum install -y libXrender libXext libXfont fontconfig装完再跑 fc-list 验证字体。5.4 分页把表格行截断行内容一半在上一页一半在下一页现象多页 PDF 里表格的一行被从中间截断上半行在上一页下半行跑到下一页。原因转换器默认在内容撑满页面时直接换页不会主动避免在行内截断。wkhtmltopdf 的 WebKit 内核支持 CSS 的 page-break-inside: avoid但需要显式声明才会生效。解决在模板 CSS 里给 tr 加 page-break-inside: avoid; 同时给 thead 加 display: table-header-group;这样每页都会重复表头。Flying Saucer 也认 page-break-inside: avoid但规则必须写在 tr 上写在 td 上无效这是它和浏览器行为的差异点。5.5 生成的 PDF 体积异常大单个文件几十 MB现象内容不到两页的对账单转出来 PDF 却有几十 MB下载和打开都卡顿。原因字体嵌入方式有问题。Flying Saucer 的 FontFactory 注册字体时没有开启子集嵌入把整个字体文件塞进 PDF中文字体动辄几 MB模板里引用的图片没压缩以原始分辨率嵌进去。解决wkhtmltopdf 没有直接控制字体子集的参数但指定系统字体可以减小体积图片先压缩到宽度不超过 800px再让 --image-quality 控制输出质量。Flying Saucer 用 FontFactory.register 时注意第三个参数传 true 开启嵌入子集体积能压到几百 KB。生成后把文件大小列入常规检验项避免大小异常成为线上事故的黑匣子。6. 把方案做得更稳字体资源、批量任务与结果自校验6.1 把字体打进 jar 并从 classpath 加载选了 Flying Saucer 的话最稳的做法是让中文字体作为应用资源随包走而不是依赖服务器安装。把字体文件放 src/main/resources/fonts/ 目录下启动时复制到临时目录再注册try (InputStream in getClass().getResourceAsStream(/fonts/simsun.ttf)) { Path temp Files.createTempFile(simsun, .ttf); Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); FontFactory.register(temp.toString(), SimSun); }之后模板里 font-family 统一写 SimSun 即可。复制的临时文件在 JVM 进程存活期间保留由系统在退出时兜底清理。6.2 批量任务用线程池但注意进程回收批量生成上百张 PDF 时逐条串行太慢。常见做法是维护一个固定大小线程池比如 4 个线程并发调 wkhtmltopdf。要注意每执行一次 ProcessBuilder 就拉起一个系统进程必须保证 waitFor 正常返回且进程输出流被及时消费否则线程池会被僵尸进程占死。我一般会给 ProcessBuilder 加超时销毁逻辑90 秒没结束就 process.destroyForcibly()把对应任务标记失败并进入重试队列。6.3 生成后用 PDFBox 抽文本做自校验PDF 生成完肉眼检查只能在开发阶段做批量任务跑完没法逐份确认。我现在的习惯是生成后用 PDFBox 抽取文本判断关键字段是否出现PDDocument doc PDDocument.load(pdfBytes); String text new PDFTextStripper().getText(doc); boolean ok text.contains(expectedBillNo); doc.close();这条逻辑看起来简单但能拦截很大一部分模板数据没渲染进去的空 PDF问题。把校验失败的任务单独落到错误目录留待人工排查。这条链路做到这里踩过的坑基本都趟平了。我现在的流程也固定成了先写最小模板验证字体和边框再写业务模板最后才接批量任务。每一步都先拿到可观测的中间产物再往下走不确定的 CSS 属性就先在内联样式里试确认无误再收进公共模板。用 PDFBox 校验后再交付至少能保证每次发给用户的不是一张空纸或一堆方框。希望这个顺序能帮你少走几趟弯路把 freemarkerhtml 出 pdf 的模块稳稳落地。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →