Java动态生成PDF文件
思路:先有个pdf模板,然后将数据放到pdf模板的对应位置即可。
生成pdf模板过程:先用html写一个pdf模板样式,然后用Java程序读取整个文件,修改数据部分做个拼接。一般从数据库查到数据,封装成一个对象,遍历这个对象,赋值给pdf模板的数据(数据替换)。然后service层返回一个byte\[\]给controller,controller调用工具类响应给前端。前端就下文件即可(文件上传下载无论是pdf还是excel我的文章里面有可以翻翻),这个是轻量级做法,但是足够小系统使用。
1.pdf的html模板:
这个文件一般放在后端resource/static下面(根据需求自己写):grade-template.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>{{trainName}}-考核成绩报告单</title>
<style>
/* 【基础重置】 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'SimSun', serif !important;
-webkit-print-color-adjust: exact; /* 强制打印背景色 */
}
/* 【页面设置】A4 纸张,边距适中 */
@page {
size: A4;
margin: 15mm 10mm; /* 上下留白稍大,左右稍窄,利用横向空间 */
}
body {
background: #ffffff;
padding: 0;
margin: 0;
}
/* 容器:不再限制高度,完全由内容撑开 */
.report-paper {
width: 100%;
/* 【修改点1】减小内边距,防止溢出 */
padding: 0;
position: relative;
}
/* 头部 Logo */
.header {
text-align: right;
margin-bottom: 4px;
height: 30px;
}
.logo-img {
height: 28px;
width: auto;
}
h1 {
text-align: center;
font-size: 18px; /* 稍微调小字号以节省空间 */
color: #0052cc;
margin-bottom: 8px;
letter-spacing: 1px;
font-weight: bold;
}
/* 基础信息表 */
.base-info {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
table-layout: fixed;
}
.base-info td {
border: 1px solid #aaa;
padding: 4px 5px; /* 减小单元格内边距 */
font-size: 11px;
vertical-align: middle;
word-wrap: break-word;
}
.base-info td:nth-child(1) { width: 14%; background: #eef5ff; font-weight: bold; text-align: center;}
.base-info td:nth-child(2) { width: 36%; }
.base-info td:nth-child(3) { width: 14%; background: #eef5ff; font-weight: bold; text-align: center;}
.base-info td:nth-child(4) { width: 36%; }
/* 明细表 - 核心修改区域 */
.detail-table {
width: 100%;
border-collapse: collapse;
margin-top: 0;
-fs-table-paginate: paginate;
}
.detail-table th {
background: #0052cc;
color: white;
padding: 4px 2px;
font-size: 10px;
font-weight: normal;
border: 1px solid #2a6ccf;
text-align: center;
vertical-align: middle;
}
.detail-table td {
border: 1px solid #aaa;
padding: 3px 2px; /* 极度压缩单元格高度 */
font-size: 9.5px; /* 稍微缩小字体以容纳更多内容 */
text-align: center;
vertical-align: middle;
word-break: break-all;
line-height: 1.2;
}
/* 列宽微调 */
.col-step { width: 11%; }
.col-question { width: 20%; }
.col-answer { width: 9%; }
.col-right { width: 9%; }
.col-score { width: 6%; }
.col-analysis { width: 30%; } /* 分析栏给多一点空间 */
.col-status { width: 8%; }
.right { color: #00843d; font-weight: bold; }
.wrong { color: #c62b2b; font-weight: bold; }
.analysis-text {
font-size: 9px;
text-align: left;
padding-left: 2px;
line-height: 1.2;
}
/* 汇总区域 */
.score-summary {
margin-top: 2px;
padding: 6px 8px;
border: 1px solid #ddd;
background: #f9f9f9;
font-size: 10px;
/* 防止汇总框被拆分到两页 */
page-break-inside: avoid;
}
.total-score {
font-size: 14px;
color: #0052cc;
font-weight: bold;
}
</style>
</head>
<body>
<div class="report-paper">
<div class="header">
<!-- 确保 logoBase64 不为空,否则 img 标签可能会占据奇怪的高度 -->
<img class="logo-img" src="{{logoBase64}}" alt="" />
</div>
<h1>{{trainName}} 成绩单</h1>
<table class="base-info">
<tr>
<td>姓名</td>
<td>{{userName}}</td>
<td>考核得分</td>
<td style="color:#0052cc; font-weight:bold;">{{totalScore}}</td>
</tr>
<tr>
<td>实训名称</td>
<td>{{trainName}}</td>
<td>零件编号</td>
<td>{{partId}}</td>
</tr>
<tr>
<td>考核时间</td>
<td colspan="3">{{time}}</td>
</tr>
</table>
<!-- 注意:这里添加了 -fs-table-paginate: paginate 样式在 CSS 类中 -->
<table class="detail-table">
<thead>
<tr>
<th class="col-step">考核步骤</th>
<th class="col-question">考题/考核点</th>
<th class="col-answer">学员作答</th>
<th class="col-right">参考答案</th>
<th class="col-score">得分</th>
<th class="col-score">满分</th>
<th class="col-analysis">考评分析</th>
<th class="col-status">操作状态</th>
</tr>
</thead>
<tbody>
{{detailsRows}}
</tbody>
</table>
<div class="score-summary">
<p>学员实际总得分:<span class="total-score">{{totalScore}}</span> 分 / 100分</p>
<p style="margin-top:2px; color:#666;">说明:考核共计11个操作步骤,第11步不计分;前10步骤单步满分10分,合计总分:<strong>100分</strong></p>
</div>
</div>
</body>
</html>
2.Java程序读取模板
在你的service层中这样写:
java
@Override
public byte[] generateGradePdf(Long gradeId) throws IOException, DocumentException {
// 1.查成绩详情
GradeDO grade = gradeMapper.selectById(gradeId);
if (grade == null) {
throw new BusinessException("成绩记录不存在");
}
UserDO userDO = userMapper.selectById(grade.getUserId());
String userName = userDO.getRealName();
// 2. 读取 HTML 模板
String template = readTemplate("grade-template.html");
// 读取图片转 Base64 示例
String logoBase64 = "";
try {
InputStream is = getClass().getClassLoader().getResourceAsStream("static/headLogo.png");
if (is != null) {
byte[] bytes = org.apache.commons.io.IOUtils.toByteArray(is);
logoBase64 = "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(bytes);
}
} catch (Exception e) {
log.error("Logo读取失败", e);
}
// jackson处理的字段可以忽略,这里根据业务需求来。
List<StepDetail> detailList = JSON.parseArray(
JSON.toJSONString(grade.getDetailJson()),
StepDetail.class
);
String time = "";
TrainDO train = trainMapper.selectById(grade.getTrainId());
if(train != null){
if(train.getBeginTime() != null){
time += train.getBeginTime().format(formatter);
}
if(train.getBeginTime() != null && train.getEndTime() != null){
time += " - " + train.getEndTime().format(formatter);
}
}
String submitTime = "";
if(grade.getCreateTime() != null){
submitTime = grade.getCreateTime().format(formatter);
}
// 3. 替换占位符
String html = template
.replace("{{logoBase64}}", logoBase64)
.replace("{{userName}}", nullToEmpty(userName))
.replace("{{trainName}}", nullToEmpty(grade.getTrainName()))
.replace("{{partId}}", nullToEmpty(grade.getPartId()))
.replace("{{totalScore}}", String.valueOf(grade.getTotalScore()))
.replace("{{detailsRows}}", buildDetailsRows(detailList))
.replace("{{time}}", nullToEmpty(time))
.replace("{{submitTime}}", nullToEmpty(submitTime));
System.out.println(html);
// 4. HTML 转 PDF
ByteArrayOutputStream pdfStream = new ByteArrayOutputStream();
ITextRenderer renderer = new ITextRenderer();
// 这里的 "simsun" 是字体名称,必须和 CSS 中的 font-family: 'SimSun' 对应
// 第二个参数是类路径下的字体文件位置
String fontPath = "fonts/Simsun.ttf";
try {
renderer.getFontResolver().addFont(
fontPath,
BaseFont.IDENTITY_H,
BaseFont.EMBEDDED // <--- 改为 EMBEDDED
);
} catch (Exception e) {
log.error("字体加载失败,请检查 resources/fonts/Simsun.ttf 是否存在", e);
throw new RuntimeException("PDF生成失败:缺少中文字体文件", e);
}
renderer.setDocumentFromString(html, "UTF-8");
renderer.layout();
renderer.createPDF(pdfStream);
// 5. 返回 PDF
return pdfStream.toByteArray();
}
private String readTemplate(String templateName) throws IOException {
StringBuilder content = new StringBuilder();
try (
InputStream inputStream = getClass().getClassLoader().getResourceAsStream("templates/" + templateName);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
}
return content.toString();
}
private String buildDetailsRows(List<StepDetail> details) {
StringBuilder rows = new StringBuilder();
for (StepDetail step : details) {
rows.append("<tr>")
// 1. 考核步骤
.append("<td>").append(nullToEmpty(step.getStep())).append("</td>")
// 2. 考题/考核点
.append("<td>").append(nullToEmpty(step.getExamQuestion() != null ? step.getExamQuestion().getQuestion() : "")).append("</td>")
// 3. 学员作答
.append("<td class=\"").append(isRight(step) ? "right" : "wrong").append("\">")
.append(formatAnswer(step.getUserAnswer())).append("</td>")
// 4. 参考答案
.append("<td class=\"right\">").append(formatAnswer(step.getExamQuestionAnswer())).append("</td>")
// 6. 得分
.append("<td>").append(step.getCount() != null ? step.getCount() : 0).append("</td>")
// 5. 满分
.append("<td>").append(step.getOperationScore() != null ? step.getOperationScore() : 0).append("</td>")
// 7. 考评分析
.append("<td class=\"analysis-text\">").append(nullToEmpty(step.getAnalysis())).append("</td>")
// 8. 操作状态
.append("<td>").append(step.getOperation() != null && step.getOperation() ? "已完成" : "未操作").append("</td>")
.append("</tr>");
}
System.out.println(rows);
return rows.toString();
}
// 添加 null 安全方法
private String nullToEmpty(String str) {
return str == null ? "" : escapeHtml(str);
}
private boolean isRight(StepDetail step) {
if (step.getUserAnswer() == null || step.getExamQuestionAnswer() == null) {
return false;
}
return step.getUserAnswer().equals(step.getExamQuestionAnswer());
}
private String formatAnswer(List<String> answer) {
if (answer == null || answer.isEmpty()) {
return "未作答";
}
return String.join("、", answer);
}
private String escapeHtml(String str) {
if (str == null) return "";
return str
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
3.controller响应给前端
java
@Operation(summary = "导出PDF成绩单")
@GetMapping("/downloadGradePdf")
public void downloadGradeById(@RequestParam Long gradeId, HttpServletResponse response) throws IOException {
try {
byte[] pdfBytes = gradeService.generateGradePdf(gradeId);
String fileName = "我的成绩单";
FileUtils.downloadPdf(response,fileName,pdfBytes);
} catch (BusinessException | IOException e) {
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}");
} catch (DocumentException e) {
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"PDF生成失败:" + e.getMessage() + "\"}");
}
}
总结:html转pdf,程序处理后响应给前端。