使用 Flying Saucer Pdf + 模板引擎生成 PDF(解决中文和图片问题)
概述
本文原创(参考自实际项目经验)介绍如何使用Flying Saucer(flying-saucer-pdf)(xhtmlrenderer)结合多种模板引擎(Velocity、FreeMarker、XHTML)生成PDF文件,并解决中文字体显示和图片嵌入问题。
核心技术栈
- Flying Saucer (xhtmlrenderer) - 将XHTML转换为PDF
- 模板引擎 - 支持Velocity、FreeMarker、原生XHTML
- Base64图片编码 - 解决图片嵌入问题
完整解决方案
1. Maven依赖配置
<dependencies>
<!-- Flying Saucer PDF生成 -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
<version>9.1.22</version>
</dependency>
<!-- Velocity模板引擎 -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.3</version>
</dependency>
<!-- FreeMarker模板引擎 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
</dependencies>
2. 通用PDF生成工具类
package com.example.pdf.core;
import com.lowagie.text.pdf.BaseFont;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xhtmlrenderer.pdf.ITextRenderer;
import org.xhtmlrenderer.pdf.ITextUserAgent;
import java.io.*;
import java.util.Map;
/**
* PDF生成核心类(原创实现)
* 解决中文显示和图片嵌入问题
*/
public class PdfGenerator {
private static final Logger log = LoggerFactory.getLogger(PdfGenerator.class);
/**
* 生成PDF文件
* @param htmlContent HTML内容
* @param outputPath 输出文件路径
* @param fontPath 中文字体路径
*/
public static void generatePdf(String htmlContent, String outputPath, String fontPath) {
try (OutputStream os = new FileOutputStream(outputPath)) {
ITextRenderer renderer = new ITextRenderer();
// 设置自定义的ResourceLoader,处理Base64图片
renderer.getSharedContext().setUserAgentCallback(
new CustomResourceLoader()
);
// 设置中文字体
if (fontPath != null && !fontPath.isEmpty()) {
setChineseFont(renderer, fontPath);
}
// 渲染PDF
renderer.setDocumentFromString(htmlContent);
renderer.layout();
renderer.createPDF(os);
log.info("PDF生成成功: {}", outputPath);
} catch (Exception e) {
log.error("PDF生成失败", e);
throw new RuntimeException("PDF生成失败", e);
}
}
/**
* 设置中文字体
*/
private static void setChineseFont(ITextRenderer renderer, String fontPath) throws Exception {
try {
// 尝试加载字体文件
File fontFile = new File(fontPath);
if (fontFile.exists()) {
renderer.getFontResolver().addFont(
fontPath,
BaseFont.IDENTITY_H,
BaseFont.EMBEDDED
);
log.debug("使用字体文件: {}", fontPath);
} else {
// 从classpath加载
InputStream fontStream = PdfGenerator.class
.getClassLoader()
.getResourceAsStream(fontPath);
if (fontStream != null) {
// 创建临时字体文件
File tempFont = File.createTempFile("font_", ".ttc");
try (FileOutputStream fos = new FileOutputStream(tempFont)) {
byte[] buffer = new byte[1024];
int len;
while ((len = fontStream.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
}
renderer.getFontResolver().addFont(
tempFont.getAbsolutePath(),
BaseFont.IDENTITY_H,
BaseFont.EMBEDDED
);
tempFont.deleteOnExit();
log.debug("使用classpath字体: {}", fontPath);
} else {
log.warn("字体文件未找到: {}", fontPath);
}
}
} catch (Exception e) {
log.warn("字体设置失败,使用默认字体", e);
}
}
/**
* 自定义资源加载器,支持Base64图片
*/
private static class CustomResourceLoader extends ITextUserAgent {
@Override
protected InputStream resolveAndOpenStream(String uri) {
// 处理Base64图片
if (uri.startsWith("data:image")) {
try {
// 提取Base64数据
String base64Data = uri.substring(uri.indexOf(",") + 1);
byte[] imageBytes = java.util.Base64.getDecoder().decode(base64Data);
return new ByteArrayInputStream(imageBytes);
} catch (Exception e) {
log.error("Base64图片解析失败", e);
return null;
}
}
return super.resolveAndOpenStream(uri);
}
}
}
3. 模板引擎封装类
package com.example.pdf.template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* 模板引擎工厂(原创实现)
* 支持Velocity、FreeMarker和原生XHTML
*/
public class TemplateEngine {
/**
* Velocity模板引擎
*/
public static class VelocityRenderer {
private final VelocityEngine velocityEngine;
public VelocityRenderer() {
velocityEngine = new VelocityEngine();
velocityEngine.setProperty("resource.loader", "class");
velocityEngine.setProperty("class.resource.loader.class",
"org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader");
velocityEngine.setProperty("input.encoding", "UTF-8");
velocityEngine.setProperty("output.encoding", "UTF-8");
velocityEngine.init();
}
public String render(String templatePath, Map<String, Object> data) throws Exception {
VelocityContext context = new VelocityContext();
if (data != null) {
data.forEach(context::put);
}
String templateContent = loadTemplate(templatePath);
StringWriter writer = new StringWriter();
velocityEngine.evaluate(context, writer, "template", templateContent);
return writer.toString();
}
}
/**
* FreeMarker模板引擎
*/
public static class FreeMarkerRenderer {
private final Configuration configuration;
public FreeMarkerRenderer(String templateDirectory) throws IOException {
configuration = new Configuration(Configuration.VERSION_2_3_32);
configuration.setDirectoryForTemplateLoading(
new File(templateDirectory)
);
configuration.setDefaultEncoding("UTF-8");
}
public String render(String templateName, Map<String, Object> data)
throws IOException, TemplateException {
Template template = configuration.getTemplate(templateName);
StringWriter writer = new StringWriter();
template.process(data, writer);
return writer.toString();
}
}
/**
* 原生XHTML模板(直接使用)
*/
public static class XhtmlRenderer {
public String render(String templatePath, Map<String, Object> data) throws Exception {
String templateContent = loadTemplate(templatePath);
// 简单替换变量(实际项目可替换为更复杂的逻辑)
if (data != null) {
for (Map.Entry<String, Object> entry : data.entrySet()) {
String placeholder = "${" + entry.getKey() + "}";
templateContent = templateContent.replace(
placeholder,
String.valueOf(entry.getValue())
);
}
}
return templateContent;
}
}
/**
* 加载模板文件
*/
private static String loadTemplate(String templatePath) throws IOException {
try (InputStream is = TemplateEngine.class
.getClassLoader()
.getResourceAsStream(templatePath)) {
if (is == null) {
throw new FileNotFoundException("模板文件未找到: " + templatePath);
}
return readInputStream(is);
}
}
/**
* 读取输入流
*/
private static String readInputStream(InputStream is) throws IOException {
StringBuilder content = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
}
return content.toString();
}
}
4. 图片处理工具类
package com.example.pdf.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Base64;
/**
* 图片处理工具类(原创实现)
* 解决Flying Saucer图片嵌入问题
*/
public class ImageProcessor {
/**
* 将图片文件转换为Base64编码
* @param imageFile 图片文件
* @return Base64编码的图片字符串
*/
public static String imageToBase64(File imageFile) throws IOException {
if (!imageFile.exists()) {
throw new IllegalArgumentException("图片文件不存在: " + imageFile.getPath());
}
byte[] imageBytes = Files.readAllBytes(imageFile.toPath());
String base64 = Base64.getEncoder().encodeToString(imageBytes);
String mimeType = getMimeType(imageFile.getName());
return String.format("data:%s;base64,%s", mimeType, base64);
}
/**
* 将图片字节数组转换为Base64编码
* @param imageBytes 图片字节数组
* @param fileName 文件名(用于确定MIME类型)
* @return Base64编码的图片字符串
*/
public static String bytesToBase64(byte[] imageBytes, String fileName) {
if (imageBytes == null || imageBytes.length == 0) {
return "";
}
String base64 = Base64.getEncoder().encodeToString(imageBytes);
String mimeType = getMimeType(fileName);
return String.format("data:%s;base64,%s", mimeType, base64);
}
/**
* 根据文件名获取MIME类型
*/
private static String getMimeType(String fileName) {
if (fileName == null) {
return "image/jpeg";
}
fileName = fileName.toLowerCase();
if (fileName.endsWith(".png")) return "image/png";
if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) return "image/jpeg";
if (fileName.endsWith(".gif")) return "image/gif";
if (fileName.endsWith(".bmp")) return "image/bmp";
if (fileName.endsWith(".svg")) return "image/svg+xml";
if (fileName.endsWith(".tiff") || fileName.endsWith(".tif")) return "image/tiff";
return "image/jpeg";
}
/**
* 下载网络图片并转换为Base64
* @param imageUrl 图片URL
* @return Base64编码的图片字符串
*/
public static String downloadImageToBase64(String imageUrl) {
try {
// 使用Hutool简化HTTP请求
byte[] bytes = cn.hutool.http.HttpUtil.downloadBytes(imageUrl);
return bytesToBase64(bytes, getFileNameFromUrl(imageUrl));
} catch (Exception e) {
throw new RuntimeException("下载图片失败: " + imageUrl, e);
}
}
/**
* 从URL提取文件名
*/
private static String getFileNameFromUrl(String url) {
if (url == null || url.isEmpty()) {
return "image.jpg";
}
int lastSlash = url.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < url.length() - 1) {
return url.substring(lastSlash + 1);
}
return "image.jpg";
}
}
5. 使用示例
5.1 使用Velocity模板
模板文件:templates/velocity/report.vm
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>业务报告</title>
<style>
body { font-family: "SimSun", sans-serif; }
.header { text-align: center; }
.logo { width: 100px; height: 60px; }
.table { border-collapse: collapse; width: 100%; }
.table th, .table td { border: 1px solid #ddd; padding: 8px; }
</style>
</head>
<body>
<div class="header">
<h1>$report.title</h1>
<p>生成日期: $dateUtil.format($report.date, "yyyy年MM月dd日")</p>
</div>
<table class="table">
<thead>
<tr>
<th>序号</th>
<th>项目名称</th>
<th>金额</th>
<th>备注</th>
</tr>
</thead>
<tbody>
#foreach($item in $report.items)
<tr>
<td>$velocityCount</td>
<td>$item.name</td>
<td>¥$number.format('#,##0.00', $item.amount)</td>
<td>$item.remark</td>
</tr>
#end
</tbody>
</table>
<div style="margin-top: 30px;">
<p>总计: ¥$number.format('#,##0.00', $report.totalAmount)</p>
#if($report.signature)
<p>签章:</p>
<img src="$report.signature" alt="电子签章" style="width: 120px;"/>
#end
</div>
</body>
</html>
Java代码:
// 使用Velocity模板生成PDF
TemplateEngine.VelocityRenderer velocityRenderer =
new TemplateEngine.VelocityRenderer();
Map<String, Object> data = new HashMap<>();
// 准备数据...
data.put("report", reportData);
// 将图片转换为Base64
String signatureBase64 = ImageProcessor.imageToBase64(
new File("signature.png")
);
data.put("report.signature", signatureBase64);
// 渲染模板
String html = velocityRenderer.render(
"templates/velocity/report.vm",
data
);
// 生成PDF
PdfGenerator.generatePdf(
html,
"report.pdf",
"fonts/simsun.ttc"
);
5.2 使用FreeMarker模板
模板文件:templates/freemarker/invoice.ftl
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>发票</title>
<style>
body { font-family: "SimSun", sans-serif; font-size: 12px; }
.invoice-table { border-collapse: collapse; width: 100%; }
.invoice-table th { background-color: #f2f2f2; }
</style>
</head>
<body>
<h2 style="text-align: center;">增值税专用发票</h2>
<table class="invoice-table">
<tr>
<td colspan="2">购方: ${buyer.name!""}</td>
<td colspan="2">销方: ${seller.name!""}</td>
</tr>
<#list items as item>
<tr>
<td>${item_index + 1}</td>
<td>${item.productName!""}</td>
<td>${item.quantity!0}</td>
<td>¥${item.price?string("#,##0.00")}</td>
</tr>
</#list>
</table>
<div style="margin-top: 20px;">
<p>合计金额: ¥${totalAmount?string("#,##0.00")}</p>
<#if qrCode??>
<img src="${qrCode}" alt="二维码" style="width: 80px; height: 80px;"/>
</#if>
</div>
</body>
</html>
Java代码:
// 使用FreeMarker模板生成PDF
TemplateEngine.FreeMarkerRenderer freemarkerRenderer =
new TemplateEngine.FreeMarkerRenderer("templates/freemarker");
Map<String, Object> data = new HashMap<>();
// 准备数据...
// 生成二维码Base64
String qrCodeBase64 = generateQrCodeBase64("发票编号: INV001");
data.put("qrCode", qrCodeBase64);
// 渲染模板
String html = freemarkerRenderer.render("invoice.ftl", data);
// 生成PDF
PdfGenerator.generatePdf(
html,
"invoice.pdf",
"/usr/share/fonts/chinese/SimSun.ttf"
);
5.3 使用原生XHTML模板
模板文件:templates/xhtml/certificate.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8"/>
<title>荣誉证书</title>
<style>
@page {
size: A4 landscape;
margin: 50px;
}
body {
font-family: "SimSun", "STSong", serif;
background-image: url('${backgroundImage}');
background-size: cover;
text-align: center;
}
.certificate {
padding: 100px;
}
.title {
font-size: 36px;
font-weight: bold;
color: #b22222;
margin-bottom: 60px;
}
.content {
font-size: 24px;
line-height: 1.8;
margin-bottom: 40px;
}
.signature {
font-size: 18px;
margin-top: 80px;
}
.stamp {
position: absolute;
right: 150px;
bottom: 150px;
width: 120px;
height: 120px;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="certificate">
<div class="title">荣誉证书</div>
<div class="content">
兹授予 <strong>${recipientName}</strong> 同志<br/>
在${year}年度${achievement}中表现突出,<br/>
特发此证,以资鼓励。
</div>
<div class="signature">
<p>${organizationName}</p>
<p>${issueDate}</p>
</div>
<img src="${stampImage}" class="stamp" alt="公章"/>
</div>
</body>
</html>
Java代码:
// 使用原生XHTML模板生成PDF
TemplateEngine.XhtmlRenderer xhtmlRenderer =
new TemplateEngine.XhtmlRenderer();
Map<String, Object> data = new HashMap<>();
data.put("recipientName", "张三");
data.put("year", "2024");
data.put("achievement", "技术研发项目");
// 将印章图片转换为Base64
String stampBase64 = ImageProcessor.imageToBase64(
new File("stamp.png")
);
data.put("stampImage", stampBase64);
// 背景图片(使用Base64或文件路径)
data.put("backgroundImage",
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAAAAAAAD/...");
// 渲染模板
String html = xhtmlRenderer.render(
"templates/xhtml/certificate.xhtml",
data
);
// 生成PDF
PdfGenerator.generatePdf(
html,
"certificate.pdf",
"classpath:fonts/simsun.ttc"
);
6. 高级功能:缓存和性能优化
package com.example.pdf.advanced;
import com.example.pdf.core.PdfGenerator;
import com.example.pdf.template.TemplateEngine;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 高级PDF生成服务(原创实现)
* 包含模板缓存、异步生成等功能
*/
public class AdvancedPdfService {
private static final Logger log = LoggerFactory.getLogger(AdvancedPdfService.class);
// 模板缓存
private final Map<String, String> templateCache = new ConcurrentHashMap<>();
// 清理缓存的调度器
private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(1);
public AdvancedPdfService() {
// 每小时清理一次缓存
scheduler.scheduleAtFixedRate(() -> {
log.info("清理模板缓存,当前大小: {}", templateCache.size());
templateCache.clear();
}, 1, 1, TimeUnit.HOURS);
}
/**
* 带缓存的模板渲染
*/
public String renderWithCache(String templatePath,
Map<String, Object> data,
String engineType) throws Exception {
String cacheKey = templatePath + "|" + engineType;
String templateContent = templateCache.get(cacheKey);
if (templateContent == null) {
templateContent = loadTemplateContent(templatePath);
templateCache.put(cacheKey, templateContent);
log.debug("缓存模板: {}", cacheKey);
}
return renderTemplate(templateContent, data, engineType);
}
/**
* 异步生成PDF
*/
public void generatePdfAsync(String templatePath,
Map<String, Object> data,
String outputPath,
String fontPath,
String engineType) {
Executors.newSingleThreadExecutor().submit(() -> {
try {
String html = renderWithCache(templatePath, data, engineType);
PdfGenerator.generatePdf(html, outputPath, fontPath);
log.info("异步PDF生成完成: {}", outputPath);
} catch (Exception e) {
log.error("异步PDF生成失败", e);
}
});
}
/**
* 批量生成PDF
*/
public void batchGeneratePdf(String templatePath,
Iterable<Map<String, Object>> dataList,
String outputPattern,
String fontPath,
String engineType) {
int index = 0;
for (Map<String, Object> data : dataList) {
String outputPath = String.format(outputPattern, index++);
generatePdfAsync(templatePath, data, outputPath, fontPath, engineType);
}
}
/**
* 加载模板内容
*/
private String loadTemplateContent(String templatePath) throws Exception {
try (java.io.InputStream is = getClass()
.getClassLoader()
.getResourceAsStream(templatePath)) {
if (is == null) {
throw new IllegalArgumentException("模板未找到: " + templatePath);
}
return new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
}
/**
* 渲染模板
*/
private String renderTemplate(String templateContent,
Map<String, Object> data,
String engineType) throws Exception {
switch (engineType.toLowerCase()) {
case "velocity":
return renderVelocity(templateContent, data);
case "freemarker":
return renderFreemarker(templateContent, data);
case "xhtml":
return renderXhtml(templateContent, data);
default:
throw new IllegalArgumentException("不支持的模板引擎: " + engineType);
}
}
private String renderVelocity(String templateContent, Map<String, Object> data)
throws Exception {
// Velocity渲染实现...
return templateContent; // 简化示例
}
private String renderFreemarker(String templateContent, Map<String, Object> data)
throws Exception {
// FreeMarker渲染实现...
return templateContent; // 简化示例
}
private String renderXhtml(String templateContent, Map<String, Object> data) {
// XHTML简单变量替换
String result = templateContent;
for (Map.Entry<String, Object> entry : data.entrySet()) {
String placeholder = "${" + entry.getKey() + "}";
result = result.replace(placeholder,
entry.getValue() != null ? entry.getValue().toString() : "");
}
return result;
}
/**
* 关闭服务
*/
public void shutdown() {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
关键问题解决方案
1. 中文字体显示问题
核心解决方案:
// 关键代码:添加中文字体
renderer.getFontResolver().addFont(
"fonts/simsun.ttc", // 字体文件路径
BaseFont.IDENTITY_H, // 使用Unicode编码
BaseFont.EMBEDDED // 嵌入字体
);
字体文件获取:
- Windows系统:
C:/Windows/Fonts/simsun.ttc - Linux系统:
/usr/share/fonts/chinese/SimSun.ttf - 将字体文件打包到项目resources中
2. 图片显示问题
Base64解决方案:
在PDF中插入 png图片的base64格式
<!-- 在HTML模板中使用Base64图片 -->
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." />
参考GitHub Issue:
3. 性能优化建议
- 模板缓存:避免重复读取和解析模板文件
- 字体缓存:字体只需加载一次
- 异步生成:批量处理时使用线程池
- 图片优化:
- 压缩图片减小体积
- 使用WebP格式(需转换)
- 懒加载非必要图片
总结
本文提供了完整的Flying Saucer PDF生成解决方案,具有以下特点:
核心优势
- 多模板引擎支持:Velocity、FreeMarker、原生XHTML
- 完美中文支持:通过嵌入字体解决乱码问题
- 图片兼容性好:Base64编码避免路径问题
- 高性能设计:支持缓存和异步生成
使用场景
- 业务报表:销售报表、财务报表
- 证书文档:毕业证、荣誉证书
- 合同协议:电子合同、协议文件
- 发票单据:增值税发票、收据
注意事项
- 字体文件需要合法授权
- Base64图片会增加HTML体积
- 复杂布局需要CSS支持
参考资料
本文代码为原创实现,参考了实际项目经验。相关技术参考:
4073

被折叠的 条评论
为什么被折叠?



