使用 Flying-Saucer-Pdf + velocity 模板引擎生成 PDF(处理中文和图片问题)

使用 Flying Saucer Pdf + 模板引擎生成 PDF(解决中文和图片问题)

概述

本文原创(参考自实际项目经验)介绍如何使用Flying Saucer(flying-saucer-pdf)(xhtmlrenderer)结合多种模板引擎(Velocity、FreeMarker、XHTML)生成PDF文件,并解决中文字体显示和图片嵌入问题。

核心技术栈

  1. Flying Saucer (xhtmlrenderer) - 将XHTML转换为PDF
  2. 模板引擎 - 支持Velocity、FreeMarker、原生XHTML
  3. 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    // 嵌入字体
);

字体文件获取

  1. Windows系统:C:/Windows/Fonts/simsun.ttc
  2. Linux系统:/usr/share/fonts/chinese/SimSun.ttf
  3. 将字体文件打包到项目resources中

2. 图片显示问题

Base64解决方案
在PDF中插入 png图片的base64格式

<!-- 在HTML模板中使用Base64图片 -->
  <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." />

参考GitHub Issue

3. 性能优化建议

  1. 模板缓存:避免重复读取和解析模板文件
  2. 字体缓存:字体只需加载一次
  3. 异步生成:批量处理时使用线程池
  4. 图片优化
    • 压缩图片减小体积
    • 使用WebP格式(需转换)
    • 懒加载非必要图片

总结

本文提供了完整的Flying Saucer PDF生成解决方案,具有以下特点:

核心优势

  1. 多模板引擎支持:Velocity、FreeMarker、原生XHTML
  2. 完美中文支持:通过嵌入字体解决乱码问题
  3. 图片兼容性好:Base64编码避免路径问题
  4. 高性能设计:支持缓存和异步生成

使用场景

  • 业务报表:销售报表、财务报表
  • 证书文档:毕业证、荣誉证书
  • 合同协议:电子合同、协议文件
  • 发票单据:增值税发票、收据

注意事项

  1. 字体文件需要合法授权
  2. Base64图片会增加HTML体积
  3. 复杂布局需要CSS支持

参考资料

本文代码为原创实现,参考了实际项目经验。相关技术参考:

  1. Flying Saucer GitHub Repository
  2. Base64 Image Support Issue
  3. Velocity Engine Documentation
  4. FreeMarker Documentation
posted @ 2026-01-17 15:39  gccbuaa  阅读(1)  评论(0)    收藏  举报