使用 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依赖配置

xml 复制代码
<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生成工具类

java 复制代码
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. 模板引擎封装类

java 复制代码
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. 图片处理工具类

java 复制代码
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

html 复制代码
<!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代码:

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

html 复制代码
<!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代码:

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

html 复制代码
<!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代码:

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. 高级功能:缓存和性能优化

java 复制代码
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. 中文字体显示问题

核心解决方案

java 复制代码
// 关键代码:添加中文字体
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 复制代码
<!-- 在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
相关推荐
willow25 分钟前
html5基础整理
html
SimonKing1 小时前
OpenCode AI辅助编程,不一样的编程思路,不写一行代码
java·后端·程序员
FastBean1 小时前
Jackson View Extension Spring Boot Starter
java·后端
Seven972 小时前
剑指offer-79、最⻓不含重复字符的⼦字符串
java
皮皮林55111 小时前
Java性能调优黑科技!1行代码实现毫秒级耗时追踪,效率飙升300%!
java
冰_河12 小时前
QPS从300到3100:我靠一行代码让接口性能暴涨10倍,系统性能原地起飞!!
java·后端·性能优化
桦说编程15 小时前
从 ForkJoinPool 的 Compensate 看并发框架的线程补偿思想
java·后端·源码阅读
躺平大鹅16 小时前
Java面向对象入门(类与对象,新手秒懂)
java
初次攀爬者17 小时前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq