PDF导出功能实现文档
一、功能概述
基于Spring Boot + Thymeleaf + Flying Saucer实现PDF导出功能,支持动态数据渲染和多附件整合。
二、核心实现
1. 控制器层 (Controller)
java
less
@RestController
@RequestMapping("/api/pdf")
public class PdfExportController {
@Autowired
private PdfGenerateService pdfGenerateService;
/**
* 导出PDF接口
* @param clueId 业务ID
* @param response HTTP响应
*/
@GetMapping("/generate")
public void generatePdf(
@RequestParam("clueId") String clueId,
HttpServletResponse response) {
try {
pdfGenerateService.generateMonitoringArchivePdf(clueId, response);
} catch (Exception e) {
log.error("导出PDF失败: {}", e.getMessage(), e);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
}
2. 服务层实现 (Service)
java
scss
@Service
@Slf4j
public class PdfGenerateServiceImpl implements PdfGenerateService {
@Autowired
private RiskClueBasicInfoMapper riskClueBasicInfoMapper;
@Autowired
private RiskCheckFamilyMapper riskCheckFamilyMapper;
@Autowired
private RiskCheckReportMapper riskCheckReportMapper;
@Autowired
private TblSysFileRelationService fileRelationService;
/**
* 生成监测档案PDF
*/
@Override
public void generateMonitoringArchivePdf(String clueId, HttpServletResponse response) throws IOException {
// 1. 查询业务数据
RiskClueBasicInfo basicInfo = riskClueBasicInfoMapper.selectById(clueId);
if (basicInfo == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "数据不存在");
return;
}
// 2. 构建数据模型
PovertyMonitoringArchive archive = createMockData(clueId);
Map<String, Object> dataModel = convertToDataModel(archive);
// 3. 设置响应头
String fileName = URLEncoder.encode(
archive.getVillageName() + "-" + archive.getHouseholdHead() + "档案.pdf",
StandardCharsets.UTF_8.name()
);
response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "attachment; filename="" + fileName + """);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
// 4. 生成并输出PDF
try {
generatePdf("archive.ftl", clueId, dataModel, response.getOutputStream());
} catch (Exception e) {
throw new RuntimeException("PDF生成失败", e);
}
}
/**
* 构建数据模型
*/
private Map<String, Object> convertToDataModel(PovertyMonitoringArchive archive) {
Map<String, Object> dataModel = new HashMap<>();
// 基础信息
dataModel.put("fileNo", archive.getFileNo());
dataModel.put("countyName", archive.getCountyName());
dataModel.put("townName", archive.getTownName());
dataModel.put("villageName", archive.getVillageName());
dataModel.put("archiveTime", formatDate(archive.getArchiveTime()));
// 核查信息
dataModel.put("checkMethod", archive.getCheckMethod());
dataModel.put("checkTime", archive.getCheckTime());
dataModel.put("householdHead", archive.getHouseholdHead());
dataModel.put("phone", archive.getPhone());
dataModel.put("familyPopulation", archive.getFamilyPopulation());
dataModel.put("householdType", archive.getHouseholdType());
dataModel.put("familyMembers", archive.getCenCheckMember());
// 收支信息
dataModel.put("totalIncome", archive.getTotalIncome());
dataModel.put("salaryIncome", archive.getSalaryIncome());
dataModel.put("businessIncome", archive.getBusinessIncome());
dataModel.put("propertyIncome", archive.getPropertyIncome());
dataModel.put("transferIncome", archive.getTransferIncome());
dataModel.put("perCapitaIncome", archive.getPerCapitaIncome());
dataModel.put("complianceExpense", archive.getComplianceExpense());
dataModel.put("claimIncome", archive.getClaimIncome());
dataModel.put("illnessExpense", archive.getIllnessExpense());
dataModel.put("educationExpense", archive.getEducationExpense());
dataModel.put("accidentExpense", archive.getAccidentExpense());
dataModel.put("otherExpense", archive.getOtherExpense());
dataModel.put("totalRigidExpense", archive.getTotalRigidExpense());
dataModel.put("netPerCapitaIncome", archive.getNetPerCapitaIncome());
// 保障信息
dataModel.put("housingSafety", archive.getHousingSafety());
dataModel.put("waterSafety", archive.getWaterSafety());
dataModel.put("schoolingStatus", archive.getSchoolingStatus());
dataModel.put("medicalInsurance", archive.getMedicalInsurance());
// 风险信息
dataModel.put("riskJudgment", archive.getRiskJudgment());
dataModel.put("riskType", archive.getRiskType());
dataModel.put("povertyCauseAnalysis", archive.getPovertyCauseAnalysis());
dataModel.put("farmerSignature", archive.getFarmerSignature());
dataModel.put("checkerSignature", archive.getCheckerSignature());
// 附件列表
dataModel.put("list2", archive.getList2());
dataModel.put("list3", archive.getList3());
dataModel.put("list4", archive.getList4());
dataModel.put("list5", archive.getList5());
dataModel.put("list6", archive.getList6());
dataModel.put("list7", archive.getList7());
dataModel.put("list8", archive.getList8());
dataModel.put("list9", archive.getList9());
dataModel.put("list11", archive.getList11());
dataModel.put("list100", archive.getList100());
return dataModel;
}
/**
* 核心PDF生成方法
*/
public void generatePdf(String ftlName, String bizId,
Map<String, Object> dataModel,
OutputStream outputStream) throws Exception {
// 1. 渲染HTML模板
String htmlContent = renderHtmlFromFtl(ftlName, dataModel);
// 2. 清理HTML为XHTML格式
String cleanedHtml = normalizeToXhtml(htmlContent);
// 3. 生成PDF
buildPdf(cleanedHtml, bizId, outputStream);
}
/**
* 渲染FreeMarker模板
*/
private static String renderHtmlFromFtl(String ftlName, Map<String, Object> dataModel) throws Exception {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_30);
cfg.setDefaultEncoding("UTF-8");
cfg.setClassForTemplateLoading(PdfExportController.class, "/template/");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
Template template = cfg.getTemplate(ftlName);
try (StringWriter writer = new StringWriter()) {
template.process(dataModel, writer);
return writer.toString();
}
}
/**
* 转换HTML为XHTML格式
*/
private static String normalizeToXhtml(String htmlContent) {
Document document = Jsoup.parse(htmlContent);
document.outputSettings()
.syntax(Document.OutputSettings.Syntax.xml)
.escapeMode(Entities.EscapeMode.xhtml)
.charset(StandardCharsets.UTF_8);
return document.html();
}
/**
* 使用Flying Saucer生成PDF
*/
private void buildPdf(String htmlContent, String bizId, OutputStream outputStream) throws Exception {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
// 创建内存缓冲区
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
// 生成PDF到内存
builder.withHtmlContent(htmlContent,
new ClassPathResource("/template/").getURL().toString());
builder.toStream(baos);
configureFonts(builder); // 配置字体
builder.run();
// 获取PDF字节数组
byte[] pdfBytes = baos.toByteArray();
// 调用上传服务存储PDF
uploadPdfViaAttachmentService(pdfBytes, bizId, "risk_report_" + System.currentTimeMillis() + ".pdf");
// 输出到响应流
baos.writeTo(outputStream);
// 更新业务状态
riskClueBasicInfoMapper.updateRiskClueIsBatchById(bizId);
log.info("PDF生成并上传成功");
} finally {
baos.close();
}
}
/**
* 配置中文字体
*/
private static void configureFonts(PdfRendererBuilder builder) throws IOException {
registerFontFromJar(builder, "template/static/fonts/FZXBSJT.TTF", "fztyst");
registerFontFromJar(builder, "template/static/fonts/FSJT.TTF", "fsjt");
registerFontFromJar(builder, "template/static/fonts/FSGB.TTF", "fsgb");
registerFontFromJar(builder, "template/static/fonts/KT.TTF", "ktgb");
registerFontFromJar(builder, "template/static/fonts/calibri.ttf", "calibri");
log.info("中文字体加载成功");
}
/**
* 从JAR包加载字体
*/
private static void registerFontFromJar(PdfRendererBuilder builder,
String fontPath,
String fontAlias) throws IOException {
try (InputStream fontStream = Thread.currentThread()
.getContextClassLoader().getResourceAsStream(fontPath)) {
if (fontStream == null) {
throw new IOException("字体文件未找到: " + fontPath);
}
// 创建临时文件并注册字体
Path tempFontFile = Files.createTempFile("pdf-font-", ".ttf");
Files.copy(fontStream, tempFontFile, StandardCopyOption.REPLACE_EXISTING);
builder.useFont(tempFontFile.toFile(), fontAlias);
tempFontFile.toFile().deleteOnExit();
}
}
/**
* 上传PDF到文件服务
*/
private void uploadPdfViaAttachmentService(byte[] pdfData,
String bizId,
String fileName) throws Exception {
String bizType = "yjsb";
String orgId = getCurrentOrgId();
// 转换为MultipartFile
MultipartFile multipartFile = new MockMultipartFile(
"file",
fileName,
"application/pdf",
new ByteArrayInputStream(pdfData)
);
// 调用文件上传服务
Map<String, String> result = fileRelationService.uploadAttachmentFile1(
multipartFile,
bizId,
bizType,
orgId
);
log.info("PDF文件关系已记录: {}", result);
}
}
3. 数据实体类
java
ini
@Data
public class PovertyMonitoringArchive {
// 基础信息
private String fileNo;
private String countyName;
private String townName;
private String villageName;
private String archiveTime;
// 核查信息
private String checkMethod;
private String checkTime;
private String householdHead;
private String phone;
private Integer familyPopulation;
private String householdType;
private List<CenCheckMember> cenCheckMember;
// 收支信息
private BigDecimal totalIncome;
private BigDecimal salaryIncome;
private BigDecimal businessIncome;
private BigDecimal propertyIncome;
private BigDecimal transferIncome;
private BigDecimal perCapitaIncome;
private BigDecimal complianceExpense;
private BigDecimal claimIncome;
private BigDecimal illnessExpense;
private BigDecimal educationExpense;
private BigDecimal accidentExpense;
private BigDecimal otherExpense;
private BigDecimal totalRigidExpense;
private BigDecimal netPerCapitaIncome;
// 保障信息
private String housingSafety;
private String waterSafety;
private String schoolingStatus;
private String medicalInsurance;
// 风险信息
private String riskJudgment;
private String riskType;
private String povertyCauseAnalysis;
private String farmerSignature;
private String checkerSignature;
// 附件列表
private List<TblSysFileRelation> list2;
private List<TblSysFileRelation> list3;
private List<TblSysFileRelation> list4;
private List<TblSysFileRelation> list5;
private List<TblSysFileRelation> list6;
private List<TblSysFileRelation> list7;
private List<TblSysFileRelation> list8;
private List<TblSysFileRelation> list9;
private List<TblSysFileRelation> list11;
private List<String> list100;
}
4. FreeMarker模板 (archive.ftl)
html
xml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 2cm;
}
body {
font-family: SimSun, "宋体";
font-size: 12px;
line-height: 1.5;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
}
.table-container {
margin: 15px 0;
page-break-inside: avoid;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
.info-table th, .info-table td {
border: 1px solid #000;
padding: 6px 8px;
vertical-align: top;
}
.section {
margin: 20px 0 30px 0;
page-break-inside: avoid;
}
.section-title {
font-size: 14px;
font-weight: bold;
margin: 15px 0 10px 0;
text-decoration: underline;
}
.image-section {
text-align: center;
margin: 20px 0;
}
.image-item {
margin: 15px 0;
}
</style>
</head>
<body>
<!-- 档案封面 -->
<div class="header">
<div class="title">NO.${fileNo}</div>
<div class="title">${countyName}档案资料</div>
<div class="title">${townName}${villageName}</div>
<div class="title">归档时间:${archiveTime}</div>
</div>
<!-- 档案目录 -->
<div class="table-container">
<div class="title">档案目录</div>
<table class="info-table">
<tr><td>1. ${countyName}核查</td><td>第2页</td></tr>
<tr><td>2. ${villageName}签到</td><td>第3页</td></tr>
<tr><td>3. ${villageName}对象提名名单</td><td>第3页</td></tr>
<tr><td>4. ${villageName}会议记录</td><td>第3页</td></tr>
<tr><td>5. ${villageName}影像资料</td><td>第4页</td></tr>
<tr><td>6. ${villageName}资料</td><td>第4页</td></tr>
<tr><td>7. ${villageName}对象报告</td><td>第4页</td></tr>
<tr><td>8. ${townName}报告</td><td>第4页</td></tr>
<tr><td>9. ${countyName}确定</td><td>第5页</td></tr>
</table>
</div>
<!-- 监测核查表 -->
<div class="section">
<div class="section-title">${countyName}核查表</div>
<div class="title">${townName}${villageName} 核查方式:${checkMethod} 核查时间:${checkTime}</div>
<table class="info-table">
<tr><th colspan="4">基本信息</th></tr>
<tr>
<td>户主姓名</td><td>${householdHead}</td>
<td>家庭人口</td><td>${familyPopulation}</td>
</tr>
<tr>
<td>联系电话</td><td>${phone}</td>
<td>户类型</td><td>${householdType}</td>
</tr>
</table>
<!-- 家庭成员表格 -->
<table class="info-table">
<tr><th colspan="4">成员</th></tr>
<tr><th>姓名</th><th>号码</th><th>与户主关系</th><th>健康状况</th></tr>
<#list familyMembers as member>
<tr>
<td>${member.name}</td>
<td>${member.idCard}</td>
<td>${member.relation}</td>
<td>${member.healthStatus}</td>
</tr>
</#list>
</table>
<!-- 收支信息表格 -->
<table class="info-table">
<tr><th colspan="4">前12个月家庭收支信息</th></tr>
<tr><td>家庭纯收入</td><td colspan="3">${totalIncome}</td></tr>
<tr>
<td>工资性收入</td><td>${salaryIncome}</td>
<td>经营净收入</td><td>${businessIncome}</td>
</tr>
<!-- 更多收支行... -->
</table>
</div>
<!-- 附件图片部分 -->
<#if list2?has_content>
<div class="section">
<div class="section-title">${villageName}民代表民主评议签到表</div>
<div class="image-section">
<#list list2 as image>
<div class="image-item">
<img src="${image.url}" width="600" alt="签到表">
</div>
</#list>
</div>
</div>
</#if>
</body>
</html>
三、Maven依赖
xml
xml
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf模板引擎 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!-- Flying Saucer PDF生成 -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
<version>9.1.22</version>
</dependency>
<!-- HTML解析 -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.15.3</version>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
</dependencies>
四、使用方式
1. 前端调用
javascript
javascript
// 导出PDF
function exportPdf(clueId) {
const url = `/api/pdf/generate?clueId=${clueId}`;
window.open(url, '_blank');
}
2. 直接访问
text
bash
GET http://localhost:8080/api/pdf/generate?clueId=123456
3. 响应头说明
text
bash
Content-Type: application/pdf
Content-Disposition: attachment; filename="XX村-张三档案.pdf"
五、核心特性
- 动态数据渲染:通过FreeMarker模板引擎动态生成HTML
- 中文支持:内置多款中文字体,确保中文显示正常
- 自动分页:CSS媒体查询实现智能分页
- 附件整合:支持图片等附件嵌入PDF
- 文件存储:生成的PDF自动上传到文件服务器
- 响应式设计:适应不同尺寸的A4页面
六、注意事项
- 字体文件 :确保字体文件存放在
resources/template/static/fonts/目录 - 模板路径 :FreeMarker模板放在
resources/template/目录 - 内存管理:大文件生成时注意内存使用
- 并发处理:高并发场景下考虑异步生成
这个方案适用于需要生成复杂格式、包含动态数据和图片的PDF文档场景。