一.简介
1.1、Java 实现动态 PDF 导出:FreeMarker 模板 + OpenHTMLtoPDF 完整指南
在日常企业级开发中,将业务数据导出为 PDF 是一个常见需求------无论是报告、合同还是质检单据。本文将结合一个医疗设备质控报告 的实际场景,分享如何使用 FreeMarker 模板引擎 + OpenHTMLtoPDF 工具,动态生成美观的 PDF,并支持多文件打包成 ZIP 下载。同时会分享一些坑点与优化方案。
1.2 核心组件
| 组件 | 作用 |
|---|---|
| FreeMarker | 模板引擎,将 Java 对象渲染为 HTML 字符串 |
OpenHTMLtoPDF (com.openhtmltopdf:pdfbox) |
将 HTML+CSS 转换为 PDF,支持中文字体 |
| Servlet / Spring MVC | 接收导出请求,输出 ZIP 流 |
二、整体流程
-
客户端请求导出(可能含筛选条件)。
-
后端查询数据,根据不同类型(日检、周检、季度检)准备对应的数据模型。
-
加载 FreeMarker 模板(
.ftl),渲染出完整 HTML。 -
调用 OpenHTMLtoPDF 将 HTML 转为 PDF 字节数组。
-
如果有多条记录,将所有 PDF 打包成一个 ZIP 文件返回。
-
响应流设置
Content-Type: application/zip,浏览器自动下载。
三、代码实现(简化版)
3.1 代码
java
public class PdfUtil {
private static final Configuration cfg;
static {
cfg = new Configuration(Configuration.VERSION_2_3_31);
// 模板放在 classpath:/templates/ 下
cfg.setClassLoaderForTemplateLoading(PdfUtil.class.getClassLoader(), "templates");
cfg.setDefaultEncoding("UTF-8");
}
// 渲染 HTML
public static String render(String templateName, Map<String, Object> data) {
try (StringWriter writer = new StringWriter()) {
Template template = cfg.getTemplate(templateName);
template.process(data, writer);
return writer.toString();
} catch (Exception e) {
throw new RuntimeException("模板渲染失败", e);
}
}
// HTML 转 PDF
public static byte[] htmlToPdf(String html) {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.withHtmlContent(html, null);
builder.toStream(out);
// 关键:加载中文字体,避免中文乱码或方块
builder.useFont(new File("C:/Windows/Fonts/simsun.ttc"), "SimSun");
builder.run();
return out.toByteArray();
} catch (Exception e) {
throw new RuntimeException("PDF生成失败", e);
}
}
}
注意 :生产环境建议将字体文件放在项目资源目录中,通过 getResourceAsStream 加载
3.2 导出服务核心逻辑(批量 ZIP)
java
@Override
public void exportMessages(List<BaseMessage> messages, HttpServletResponse response) {
ZipOutputStream zipOut = null;
try {
if (messages == null || messages.isEmpty()) {
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("无数据可导出");
return;
}
response.setContentType("application/zip");
response.setHeader("Content-Disposition",
"attachment;filename=reports_" + System.currentTimeMillis() + ".zip");
zipOut = new ZipOutputStream(response.getOutputStream());
for (BaseMessage msg : messages) {
try {
// 1. 构建数据模型
Map<String, Object> dataModel = buildDataModel(msg);
// 2. 选择模板(日检/周检等)
String template = getTemplateByType(msg.getCheckType());
// 3. 渲染 HTML
String html = PdfUtil.render(template, dataModel);
// 4. 转 PDF
byte[] pdfBytes = PdfUtil.htmlToPdf(html);
// 5. 生成文件名
String fileName = msg.getEquipment() + "_" + msg.getCheckTime() + ".pdf";
ZipEntry entry = new ZipEntry(fileName);
zipOut.putNextEntry(entry);
zipOut.write(pdfBytes);
zipOut.closeEntry();
} catch (Exception e) {
log.error("导出单条失败:{}", msg.getId(), e);
// 继续处理下一条
}
}
} catch (Exception e) {
log.error("ZIP打包失败", e);
} finally {
if (zipOut != null) { try { zipOut.close(); } catch (IOException ignored) {} }
}
}
3.3 模板示例(日检报告片段)
java
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>质控报告</title>
<style>
@page { size: A4; margin: 20mm; }
body { font-family: SimSun; font-size: 14px; }
.title { color: #016fa0; text-align: center; font-size: 24px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #aaa; padding: 8px; text-align: center; }
.table-header { background-color: #016fa0; color: white; }
</style>
</head>
<body>
<div class="title">医用电子直线加速器质量控制检查报告</div>
<div class="info">
<span>检查日期:${(checkDate?string("yyyy-MM-dd"))!}</span>
<span>设备:${equipment!}</span>
<span>检查人:${inspector!}</span>
</div>
<h3>等中心指示(激光灯)技术要求:≤2mm</h3>
<table>
<tr class="table-header">
<th>检查位置</th><th>机架角度</th><th>偏差(mm)</th><th>判定</th>
</tr>
<#list dayWaitList as item>
<tr>
<td>${item.position!}</td>
<td>${item.angle!}</td>
<td>${item.deviation!}</td>
<td>${item.result!}</td>
</tr>
</#list>
</table>
<div class="sign">检查人签字:________ 复核人签字:________</div>
</body>
</html>
四、常见问题与解决方案
4.1 表格超出页面被截断(重点)
你很可能遇到过:生成的 PDF 中表格右侧被裁切,尤其列数较多时。
原因分析:
-
纸张 A4 可用宽度约为 170mm(210mm - 左右边距20mm×2)。
-
模板中
body或外层容器设置了固定宽度(如180mm)又加上padding,实际占用宽度超过 170mm。 -
浏览器/PDF 引擎不会横向滚动,直接裁剪超出部分。
解决方案:
-
使用相对宽度 :外层容器
width: 100%; box-sizing: border-box; -
去掉固定宽度的表格 :
table { width: 100%; table-layout: auto; } -
控制列宽比例 :给
<th>设置百分比宽度,如width: 15% -
强制文字换行 :
td { word-break: break-word; } -
减小页边距 :
@page { margin: 15mm; }增加可用宽度。
示例修正:
css
* { box-sizing: border-box; }
@page { size: A4; margin: 15mm; }
body { width: 100%; margin: 0; }
table { width: 100%; table-layout: auto; }
td, th { word-break: break-word; }
4.2 中文乱码 / 方块
-
必须在
PdfRendererBuilder中指定中文字体,如builder.useFont(simsunFont, "SimSun")。 -
HTML 中的
font-family需与该字体名称匹配。
4.3 分页时表格行被截断
使用 CSS 分页控制:
css
tr { page-break-inside: avoid; }
thead { display: table-header-group; } /* 每页重复表头 */
4.4 大并发下性能问题
-
FreeMarker 模板实例是线程安全的,可缓存。
-
每次生成 PDF 比较耗 CPU,建议对简单报告增加缓存或异步生成。
-
批量导出时控制单次打包数量(如 ≤50),避免内存溢出。
通过 FreeMarker + OpenHTMLtoPDF,我们可以像写普通网页一样设计 PDF 模板,极大降低了报表开发门槛。关键点在于:
-
模板与数据分离,易于维护
-
正确处理中文与页面截断问题
-
批量导出时使用 ZIP 流,提升用户体验
本文提供的代码片段可直接应用于 Spring Boot 项目中。如果你也遇到了表格超出、中文乱码等困扰,希望这篇文章能帮你快速避坑。
最终结果
