1. pdf ftl 模版

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"/>
    <title>工程材料进场查验记录表</title>
    <style>
        /* CSS样式保持不变 */
        @font-face {font-family: 'SimSun';src: url('../fonts/simsun.ttf');-fs-pdf-font-embed: embed;-fs-pdf-font-encoding: Identity-H;}@page {size: A4 landscape;margin: 1cm;}body {font-family: 'SimSun', sans-serif;font-size: 9px;}table {width: 100%;border-collapse: collapse;border: 1px solid black;table-layout: fixed;}th, td {border: 1px solid black;padding: 2px;text-align: center;word-wrap: break-word;word-break: break-all;height: 20px;}.header-table {border: none;width: 100%;}.header-table td {border: none;text-align: left;padding: 2px 0;font-size: 12px;}.header-table .right {text-align: right;}.main-title {text-align: center;font-size: 18px;font-weight: bold;margin-bottom: 10px;}.align-left {text-align: left;}.signature-img {max-width: 40px;max-height: 15px;object-fit: contain;}
    </style>
</head>
<body>

<#macro signature roleName historyList>
    <#if historyList?has_content>
        <#list historyList as history>
            <#if history.stepName?has_content && history.stepName == roleName>
                <#if history.signatureBase64?has_content && history.signatureBase64 != "">
                    <img class="signature-img" src="data:image/png;base64,${history.signatureBase64}"/>
                <#else>
                    ${history.userName!""}
                </#if>
                <#break>
            </#if>
        </#list>
    </#if>
</#macro>

<div class="main-title">工程材料进场查验记录表</div>

<table class="header-table">
    <tr>
        <td>工程名称: ${project_name!""}</td>
        <td>案号: ${project_id!""}</td>
        <td>施工单位: ${factory_name!""}</td>
        <td class="right">表单号: ${materialrecord_id!""}</td>
    </tr>
</table>

<br/>

<table>
    <thead>
    <tr>
        <th rowspan="2" style="width: 3%;">项次<br/>编号</th>
        <th rowspan="2" style="width: 8%;">项目名称</th>
        <th rowspan="2" style="width: 10%;">项目特征及记述</th>
        <th rowspan="2" style="width: 4%;">计量<br/>单位</th>
        <th rowspan="2" style="width: 5%;">总工程量</th>
        <th rowspan="2" style="width: 5%;">已查验<br/>总量</th>
        <#if itemcount?is_number && itemcount gt 0>
            <#list 1..itemcount as i><th colspan="8">${"第" + i + "批"}</th></#list>
        <#else>
            <th colspan="8">无查验批次</th>
        </#if>
    </tr>
    <tr>
        <#if itemcount?is_number && itemcount gt 0>
            <#list 1..itemcount as i>
                <th style="width: 5%;">进场日期</th><th style="width: 350px;">品牌、规格</th>
                <th style="width: 4%;">数量</th><th style="width: 4%;">查验结果</th>
                <th style="width: 4%;">监理</th><th style="width: 4%;">品管</th>
                <th style="width: 4%;">需求单位</th><th style="width: 5%;">备注</th>
            </#list>
        </#if>
    </tr>
    </thead>
    <tbody>
    <#list materialRcordInfo as item>
        <tr>
            <td>${item.rownum!""}</td>
            <td class="align-left">${item.itemName!""}</td>
            <td class="align-left">${item.itemspecifications!""}</td>
            <td>${item.itemUnit!""}</td>
            <td>${(item.itemtotal!0)?string("0.##")}</td>
            <td>${(item.checktotal!0)?string("0.##")}</td>

            <#-- 1. 动态创建一个Map,【核心修复1】将数字键(batchNo)强制转换为字符串键 -->
            <#assign batchMap = {}>
            <#if item.materialrecordDetails?has_content>
                <#list item.materialrecordDetails as detail>
                    <#if detail.batchNo?is_number>
                    <#-- ?c 将数字转换为字符串 -->
                        <#assign batchMap = batchMap + {detail.batchNo?c: detail}>
                    </#if>
                </#list>
            </#if>

            <#if itemcount?is_number && itemcount gt 0>
                <#list 1..itemcount as i>
                <#-- 【核心修复2】使用字符串键来访问Map,不再需要?api -->
                    <#assign batch = batchMap[i?c]!> <#-- 使用!确保即使找不到key也不会报错 -->

                    <#if batch?has_content>
                        <td>
                            <#if batch.materialcheckDate?has_content && batch.materialcheckDate != "0001-01-01 00:00:00">
                                ${batch.materialcheckDate?keep_before(" ")}
                            </#if>
                        </td>
                        <td class="align-left">
                            <#assign displaySpec = "">
                            <#if batch.brand?has_content && batch.brand?trim != "/"><#assign displaySpec = batch.brand?trim></#if>
                            <#if batch.specifications?has_content><#assign displaySpec = displaySpec + " " + batch.specifications?trim></#if>
                            ${displaySpec?trim}
                        </td>
                        <td>
                            <#if (batch.currentQuantity!0) gt 0>${batch.currentQuantity?string("0.##")}</#if>
                        </td>
                        <td>${batch.checkStatus!""}</td>
                        <td><@signature roleName="監理" historyList=batch.historyList![] /></td>
                        <td><@signature roleName="品管" historyList=batch.historyList![] /></td>
                        <td><@signature roleName="需求單位" historyList=batch.historyList![] /></td>
                        <td>${batch.checkRemark!""}</td>
                    <#else>
                        <td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>
                    </#if>
                <#else>
                    <td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>
                </#list>
            </#if>
        </tr>
    </#list>
    </tbody>
</table>
</body>
</html>

2. 利用java读取ftl 模版,渲染数据,生成pdf 文件

  1. 定义controller,供第三方调用
     
/**
	 * 接收物料記錄的 JSON 數據,生成 PDF 並返回 Base64 編碼
	 *
	 * @param recordData 前端傳來的 JSON 會自動被 Spring Boot 轉換為 MaterialRecordVO 對象
	 * @return 包含 Base64 字符串的 JSON 響應
	 */
	@PostMapping("/generateMaterialRecord")
	@ApiOperation(value = "工程材料验收检验表pdf生成")
	public String generateMaterialRecord(@RequestBody MaterialRecordVO recordData) {
		String fileId="";
		try {
			byte[] bytes = pdfGeneratorService.generateMaterialRecordPdf(recordData);
			if(ObjectUtil.isNotEmpty(bytes)){
				fileId= signFormService.uploadFileByFile(bytes);
			}
			return fileId;
		} catch (Exception e) {
			e.printStackTrace(); // 應該使用日誌框架記錄錯誤
			// 返回錯誤信息
			Map<String, String> errorResponse = Collections.singletonMap("error", "PDF 生成失敗: " + e.getMessage());
			return fileId;
		}
	}

2. service 组件

package com.foxconn.ProjectVSAPI.basic.service;

import com.foxconn.ProjectVSAPI.basic.common.vo.MaterialRecordVO;
import com.lowagie.text.pdf.BaseFont;
import freemarker.template.Configuration;
import freemarker.template.Template;
import org.springframework.stereotype.Service;
import org.xhtmlrenderer.pdf.ITextFontResolver;
import org.xhtmlrenderer.pdf.ITextRenderer;

import java.io.ByteArrayOutputStream;
import java.io.StringWriter;
import java.net.URL;
import java.util.Base64;

@Service
public class IPdfGeneratorService {
    // 從 Classpath 加載資源的路徑
    private static final String FONT_RESOURCE_PATH = "/fonts/SimSun.ttf";
    private static final String TEMPLATE_NAME = "recordTemplate.ftl";
    private static final String TEMPLATE_DIRECTORY = "/templates/";

    // Freemarker 配置,對於 Singleton Service,最好只初始化一次
    private final Configuration freemarkerCfg;

    public IPdfGeneratorService() {
        // 在構造函數中初始化 Freemarker 配置
        freemarkerCfg = new Configuration(Configuration.getVersion());
        // 設置模板加載器以從 Classpath 加載
        freemarkerCfg.setClassForTemplateLoading(this.getClass(), TEMPLATE_DIRECTORY);
        freemarkerCfg.setDefaultEncoding("UTF-8");
    }

    public String generateMaterialRecordPdfAsBase64(MaterialRecordVO recordData) throws Exception {
        // 步驟 1: 使用 FreeMarker 渲染 HTML
        String htmlContent = processTemplate(recordData);

        // 步驟 2: 在記憶體中將 HTML 轉換為 PDF 字節數組
        byte[] pdfBytes = generatePdfBytesFromHtml(htmlContent);

        // 步驟 3: 將字節數組編碼為 Base64 字串並返回
        return Base64.getEncoder().encodeToString(pdfBytes);
    }
    public byte[] generateMaterialRecordPdf(MaterialRecordVO recordData) throws Exception {
        // 步驟 1: 使用 FreeMarker 渲染 HTML
        String htmlContent = processTemplate(recordData);

        // 步驟 2: 在記憶體中將 HTML 轉換為 PDF 字節數組
        byte[] pdfBytes = generatePdfBytesFromHtml(htmlContent);

        // 步驟 3: 將字節數組編碼為 Base64 字串並返回
        return pdfBytes;
    }
    /**
     * 處理模板,將 VO 數據填充到 FTL 中生成 HTML
     * @param recordData 數據對象
     * @return 渲染後的 HTML 字符串
     */
    private String processTemplate(MaterialRecordVO recordData) throws Exception {
        Template template = freemarkerCfg.getTemplate(TEMPLATE_NAME);
        try (StringWriter out = new StringWriter()) {
            template.process(recordData, out);
            return out.toString();
        }
    }

    /**
     * 從 HTML 內容生成 PDF 字節數組
     * @param htmlContent 渲染後的 HTML
     * @return PDF 的 byte[]
     */
    private byte[] generatePdfBytesFromHtml(String htmlContent) throws Exception {
        // 使用 try-with-resources確保 ByteArrayOutputStream 被關閉
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            ITextRenderer renderer = new ITextRenderer();
            ITextFontResolver fontResolver = renderer.getFontResolver();

            // 從 Classpath 獲取字體資源的 URL
            URL fontUrl = getClass().getResource(FONT_RESOURCE_PATH);
            if (fontUrl == null) {
                throw new IllegalStateException("字體文件未在 Classpath 中找到: " + FONT_RESOURCE_PATH);
            }

            // 添加字體,這是處理中文的關鍵
            fontResolver.addFont(fontUrl.toURI().getPath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

            // 將 baseURL 設置為模板目錄,以便解析相對路徑 (如果模板中有)
            URL baseUrl = getClass().getResource(TEMPLATE_DIRECTORY);

            renderer.setDocumentFromString(htmlContent, baseUrl != null ? baseUrl.toExternalForm() : null);
            renderer.layout();
            renderer.createPDF(baos);
            renderer.finishPDF();

            return baos.toByteArray();
        }
    }
}

3. 动态生成带签名的pdf 文件

controller:

@PostMapping("/signMaterialPdfPage")
	@ApiOperation(value = "生成工程材料检验表pdf每页页尾签名")
	public Map<String, Object> exportMaterialPdfPage(@RequestBody MaterialRecordVO materialRecordVO) {
		Map<String, Object> resultMap=new HashMap<>();
		String fileId="";
		byte[] pdfWithSignature = PdfGenUtils.createPDFWithSignaturePage(PdfUtil.PDF_PROJECT_TEMPLATE, materialRecordVO);
		if(ObjectUtil.isNotEmpty(pdfWithSignature)){
			fileId= signFormService.uploadFileByFile(pdfWithSignature);
		}
		resultMap.put("fileId",fileId);
		return resultMap;
	}

service(公共类)

package com.foxconn.ProjectVSAPI.basic.utils;


import com.foxconn.ProjectVSAPI.basic.common.vo.SignNode;
import com.itextpdf.forms.PdfAcroForm;
import com.itextpdf.forms.fields.PdfFormField;
import com.itextpdf.forms.fields.PdfSignatureFormField;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.colors.ColorConstants;
import com.itextpdf.kernel.font.FontUtil;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.annot.PdfAnnotation;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.properties.TextAlignment;
import com.lowagie.text.pdf.BaseFont;
import freemarker.template.Configuration;
import freemarker.template.Template;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.xhtmlrenderer.pdf.ITextFontResolver;
import org.xhtmlrenderer.pdf.ITextRenderer;

import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.util.*;

/**
 * 生成pdf文件工具类
 *
 * @author
 * @create 2020/01/10
 */
@Slf4j
public class PdfGenUtils {

    private final static String TEMPLATE_BASE_PATH = "/templates/"; // 存放文件模板的地址
    private final static String IMG_BASE_PATH = "/app/static/conf/"; // 存放图片文件的地址
    private final static String FONT_BASE_PATH = "fonts/SimSun.ttf";
    private final static String FONT_BASE_PATHd = "fonts/SimHei.ttf";
    private final static String DEFAULT_FONT = ""; // 默认字体资源文件
    private final static String ENCODING = "UTF-8"; // 指定编码
    public final static String PDF_DEMO_IMAGE = "demo_image.png"; // 图片名称
    public final static String PDF_DEMO_TEMPLATE = "attachment_panel.ftl"; // 模板名称
    public final static String RETAINAPPLY_PDF_DEMO_TEMPLATE = "retainApplyPdf-template.ftl"; // 模板名称
    public final static String RETAINCANCEL_PDF_DEMO_TEMPLATE = "retainCancelPdf-template.ftl"; // 模板名称
    public final static String PDF_DEMO_LIST_TEMPLATE = "list_pdf.ftl"; // 列表模板名称

    /**
     * 生成PDF并添加签名域,然后输出到响应流
     *
     * @param templateCode 模板
     * @param data         传入到freemarker模板里的数据
     * @param response     HTTP响应
     */
    public static byte[] createPDFWithSignature(String templateCode, Object data) {
        byte[] signedPdfBytes = null;
        try {
            // 生成PDF字节数组
            byte[] pdfBytes = createPDF(templateCode, data);

            // 在PDF最后一页添加签名域
            signedPdfBytes = addSignatureFieldToLastPage(pdfBytes, data);
//            // 设置响应头
//            response.setContentType("application/pdf");
//            response.setHeader("Content-Disposition", "attachment; filename=document.pdf");
//            if (signedPdfBytes != null) {
//                response.setContentLength(signedPdfBytes.length);
//            }
//
//            // 输出到响应流
//            OutputStream out = response.getOutputStream();
//            if (signedPdfBytes != null) {
//                out.write(signedPdfBytes);
//            }
//            out.flush();
        } catch (Exception e) {
            log.error("生成带签名域的PDF异常", e);
        }

        return signedPdfBytes;
    }
    /**
     * 生成PDF 每页并添加签名域,然后输出到响应流
     *
     * @param templateCode 模板
     * @param data         传入到freemarker模板里的数据
     * @param response     HTTP响应
     */
    public static byte[] createPDFWithSignaturePage(String templateCode, Object data) {
        byte[] signedPdfBytes = null;
        try {
            // 生成PDF字节数组
            byte[] pdfBytes = createPDF(templateCode, data);

            // 在PDF 每页页尾添加签名域
            signedPdfBytes = addSignatureFieldToEveryPage(pdfBytes, data);
//            // 设置响应头
//            response.setContentType("application/pdf");
//            response.setHeader("Content-Disposition", "attachment; filename=document.pdf");
//            if (signedPdfBytes != null) {
//                response.setContentLength(signedPdfBytes.length);
//            }
//
//            // 输出到响应流
//            OutputStream out = response.getOutputStream();
//            if (signedPdfBytes != null) {
//                out.write(signedPdfBytes);
//            }
//            out.flush();
        } catch (Exception e) {
            log.error("生成带签名域的PDF异常", e);
        }

        return signedPdfBytes;
    }
    /**
     * 生成pdf并返回byte[]
     *
     * @param templateCode 模板
     * @param data         传入到freemarker模板里的数据
     * @return 生成的pdf的byte数组
     */
    public static byte[] createPDF1(String templateCode, Object data) {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        try {
            // 创建一个FreeMarker实例, 负责管理FreeMarker模板的Configuration实例
            Configuration cfg = new Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
            // 指定FreeMarker模板文件的位置
            cfg.setClassForTemplateLoading(PdfUtil.class, TEMPLATE_BASE_PATH);
            ITextRenderer renderer = new ITextRenderer();
            ITextFontResolver fontResolver = renderer.getFontResolver();

            // 设置 css中 的字体样式
            renderer.getFontResolver().addFont(FONT_BASE_PATH, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
            renderer.getFontResolver().addFont(FONT_BASE_PATHd, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);

            // 设置模板的编码格式
            cfg.setEncoding(Locale.CHINA, ENCODING);
            // 获取模板文件
            Template template = cfg.getTemplate(templateCode, ENCODING);
            StringWriter writer = new StringWriter();
            // 将数据输出到html中
            template.process(data, writer);
            writer.flush();
            String html = writer.toString();

            // 把html代码传入渲染器中
            renderer.setDocumentFromString(html);
            renderer.layout();
            renderer.createPDF(outputStream, false);
            renderer.finishPDF();
            outputStream.flush();

            return outputStream.toByteArray();
        } catch (Exception e) {
            log.error("PDF导出异常", e);
            return null;
        } finally {
            try {
                outputStream.close();
            } catch (IOException e) {
                log.error("关闭流异常", e);
            }
        }
    }

    public static byte[] createPDF(String templateCode, Object data) {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        try {
// 创建一个FreeMarker实例, 负责管理FreeMarker模板的Configuration实例
            Configuration cfg = new Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
// 指定FreeMarker模板文件的位置
// *** 注意:這裡的 PdfUtil.class 應改為您自己的類名,例如 PdfGenUtils.class ***
            cfg.setClassForTemplateLoading(PdfGenUtils.class, TEMPLATE_BASE_PATH);
            ITextRenderer renderer = new ITextRenderer();
            ITextFontResolver fontResolver = renderer.getFontResolver();
// ====================【核心修改部分:從 Classpath 加載字體】====================
// 不再使用檔案路徑,而是從 classpath 加載字體流並傳遞給 ITextRenderer
            fontResolver.addFont(loadFontFromClasspath(FONT_BASE_PATH), BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
            fontResolver.addFont(loadFontFromClasspath(FONT_BASE_PATHd), BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
// =======================================================================
// 设置模板的编码格式
            cfg.setEncoding(Locale.CHINA, ENCODING);
// 获取模板文件
            Template template = cfg.getTemplate(templateCode, ENCODING);
            StringWriter writer = new StringWriter();
// 将数据输出到html中
            template.process(data, writer);
            writer.flush();
            String html = writer.toString();
// 把html代码传入渲染器中
            renderer.setDocumentFromString(html);
            renderer.layout();
            renderer.createPDF(outputStream, false);
            renderer.finishPDF();
            outputStream.flush();
            return outputStream.toByteArray();
        } catch (Exception e) {
            log.error("PDF导出异常", e);
            return null;
        } finally {
            try {
                outputStream.close();
            } catch (IOException e) {
                log.error("关闭流异常", e);
            }
        }
    }

    /**
     * 從 Classpath 加載字體資源,並將其寫入臨時檔案,返回該臨時createChineseFont檔案的路徑。
     * 這是為了相容需要檔案路徑的舊版函式庫(如 ITextRenderer)。
     *
     * @param fontClasspathPath 字體在 classpath 中的路徑 (e.g., "fonts/SimSun.ttf")
     * @return 臨時字體檔案的絕對路徑
     * @throws IOException 如果找不到字體或寫入臨時檔案失敗
     */
    private static String loadFontFromClasspath(String fontClasspathPath) throws IOException {
        try (InputStream fontStream = PdfGenUtils.class.getClassLoader().getResourceAsStream(fontClasspathPath)) {
            if (fontStream == null) {
                throw new IOException("無法在 classpath 中找到字體檔案: " + fontClasspathPath);
            }
// 創建一個臨時檔案來存放字體
            File tempFontFile = File.createTempFile("font-", ".ttf");
// 讓 JVM 在退出時自動刪除這個臨時檔案
            tempFontFile.deleteOnExit();
// 將 classpath 中的字體流複製到臨時檔案中
            try (OutputStream out = new FileOutputStream(tempFontFile)) {
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = fontStream.read(buffer)) != -1) {
                    out.write(buffer, 0, bytesRead);
                }
            }
// 返回臨時檔案的絕對路徑
            return tempFontFile.getAbsolutePath();
        }
    }
   /*
   * 在最后一页增加签名
   * */
    public static byte[] addSignatureFieldToLastPage(byte[] pdfBytes, Object data) {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        PdfReader reader = null;
        PdfWriter writer = null;
        PdfDocument pdfDoc = null;

        try {
            // 初始化PDF文档
            reader = new PdfReader(new ByteArrayInputStream(pdfBytes));
            writer = new PdfWriter(outputStream);
            pdfDoc = new PdfDocument(reader, writer);

            // 解析并分组签名数据
            List<SignGroup> signGroups = groupSignUsers(parseSignUserList(data));

            // 按节点序号降序排序(确保从右到左:序号大的在右侧,序号小的在左侧)
            signGroups.sort((g1, g2) -> g1.getSort() - g2.getSort());

            // 获取页面信息
            int lastPageNum = pdfDoc.getNumberOfPages();
            Rectangle pageSize = pdfDoc.getPage(lastPageNum).getPageSize();
            PdfAcroForm form = PdfAcroForm.getAcroForm(pdfDoc, true);
            PdfFont chineseFont = createChineseFont();

            // 布局参数(可调整)
            float margin = 30f;           // 页边距
            float fieldWidth = 70f;       // 签名框宽度
            float fieldHeight = 30f;      // 签名框高度
            float userSpacing = 10f;      // 同组用户间距
            float groupSpacing = 20f;     // 节点组间距
            float labelFontSize = 8f;     // 标签字体大小
            float labelMargin = 5f;       // 标签与签名区域间距
            float lineSpacing = 40f;      // 行间距
            float labelHeight = labelFontSize + 5f; // 标签行高度

            // 初始化布局变量(从页面右下角开始布局)
            float currentY = margin;  // 当前Y坐标
            float currentX = pageSize.getWidth() - margin; // 从右侧开始
            float maxX = margin;      // 页面最小X坐标(左边界)
            float maxY = pageSize.getHeight() - margin; // 页面最大Y坐标

            // 创建文本画布(复用)
            Canvas textCanvas = new Canvas(pdfDoc.getPage(lastPageNum), pageSize);
            textCanvas.setFont(chineseFont).setFontSize(labelFontSize);

            // 分组处理签名区域(从右到左:承办 → 审核 → 核准)
            for (SignGroup group : signGroups) {
                String stepName = group.getStepName() + ":";
                String[] userNames = group.getUserNames();
                int userCount = userNames.length;

                // 计算标签宽度
                float labelWidth = chineseFont.getWidth(stepName, labelFontSize);

                // 计算签名区域总宽度
                float signatureAreaWidth = fieldWidth * userCount + userSpacing * (userCount - 1);

                // 计算组总宽度(标签 + 签名区域 + 间距)
                float totalGroupWidth = labelWidth + labelMargin + signatureAreaWidth + groupSpacing;

                // 检查是否需要换行(水平空间不足)
                if (currentX - totalGroupWidth < maxX) {
                    // 换行处理:重置X坐标到右侧,Y坐标下移
                    currentX = pageSize.getWidth() - margin;
                    currentY += lineSpacing;

                    // 检查垂直空间是否足够
                    if (currentY + fieldHeight > maxY) {
                        // 创建新页逻辑(这里简单重置Y坐标)
                        currentY = margin;
                    }
                }

                // 调整当前X位置(为组预留空间)
                currentX -= totalGroupWidth;

                // ===== 1. 绘制节点标签(在签名区域左侧)=====
                float labelX = currentX;
                float labelY = currentY + fieldHeight / 2 + labelFontSize / 2; // 垂直居中
                textCanvas.showTextAligned(stepName, labelX, labelY, TextAlignment.LEFT);

                // ===== 2. 绘制签名区域(在标签右侧)=====
                float signatureX = currentX + labelWidth + labelMargin;
                for (String name : userNames) {
                    String userName = name.trim();
                    Rectangle rect = new Rectangle(signatureX, currentY, fieldWidth, fieldHeight);

                    // 创建签名域
                    PdfSignatureFormField sigField = PdfSignatureFormField.createSignature(pdfDoc, rect);
                    sigField.setFieldName(userName);
                    setupFieldStyle(sigField);
                    form.addField(sigField, pdfDoc.getPage(lastPageNum));

                    signatureX += fieldWidth + userSpacing;
                }

                // 更新X坐标(添加组间距)
                currentX -= groupSpacing;
            }

            pdfDoc.close();
            return outputStream.toByteArray();
        } catch (Exception e) {
            log.error("添加签名域失败", e);
            closeResources(pdfDoc, reader, writer, outputStream);
            return pdfBytes;
        }
    }


    /*
    *
    *  在每一页的页面追加签名
    * */
    public static byte[] addSignatureFieldToEveryPage(byte[] pdfBytes, Object data) {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        PdfReader reader = null;
        PdfWriter writer = null;
        PdfDocument pdfDoc = null;

        try {
            // 1. 初始化PDF文檔
            reader = new PdfReader(new ByteArrayInputStream(pdfBytes));
            writer = new PdfWriter(outputStream);
            pdfDoc = new PdfDocument(reader, writer);

            // 2. 解析並分組簽名數據 (此邏輯在迴圈外執行一次即可)
            List<SignGroup> signGroups = groupSignUsers(parseSignUserList(data));

            // 按節點序號降序排序 (確保從右到左佈局:承辦 -> 審核 -> 核准)
            signGroups.sort((g1, g2) -> Integer.compare(g2.getSort(), g1.getSort()));

            // 3. 獲取文檔級別的對象
            PdfAcroForm form = PdfAcroForm.getAcroForm(pdfDoc, true);
            PdfFont chineseFont = createChineseFont(); // 假設此方法創建並返回中文字體

            // 4. 佈局參數 (可調整)
            float margin = 30f;           // 頁邊距
            float fieldWidth = 70f;       // 簽名框寬度
            float fieldHeight = 30f;      // 簽名框高度
            float userSpacing = 10f;      // 同組用戶間距
            float groupSpacing = 20f;     // 節點組間距
            float labelFontSize = 8f;     // 標籤字體大小
            float labelMargin = 5f;       // 標籤與簽名區域間距
            float lineSpacing = 40f;      // 行間距

            // ==================== 主要修改點:遍歷所有頁面 ====================
            int numberOfPages = pdfDoc.getNumberOfPages();
            for (int i = 1; i <= numberOfPages; i++) {
                PdfPage currentPage = pdfDoc.getPage(i);
                Rectangle pageSize = currentPage.getPageSize();

                // 5. 為每一頁重置佈局變量 (從頁面右下角開始佈局)
                float currentY = margin;
                float currentX = pageSize.getWidth() - margin; // 從右側開始
                float pageLeftBoundary = margin; // 當前頁的左邊界

                // 6. 為每一頁創建獨立的畫布
                Canvas textCanvas = new Canvas(currentPage, pageSize);
                textCanvas.setFont(chineseFont).setFontSize(labelFontSize);

                // 7. 分組處理簽名區域 (從右到左)
                for (SignGroup group : signGroups) {
                    String stepName = group.getStepName() + ":";
                    String[] userNames = group.getUserNames();
                    int userCount = userNames.length;

                    float labelWidth = chineseFont.getWidth(stepName, labelFontSize);
                    float signatureAreaWidth = fieldWidth * userCount + userSpacing * (userCount - 1);
                    float totalGroupWidth = labelWidth + labelMargin + signatureAreaWidth;

                    // 檢查是否需要換行
                    if (currentX - totalGroupWidth < pageLeftBoundary) {
                        currentX = pageSize.getWidth() - margin; // 重置X到最右邊
                        currentY += lineSpacing; // Y座標下移一行
                    }

                    // 為當前組佈局更新X座標
                    currentX -= totalGroupWidth;

                    // 繪製節點標籤 (在簽名區左側)
                    float labelX = currentX;
                    float labelY = currentY + (fieldHeight - labelFontSize) / 2; // 垂直居中對齊
                    textCanvas.showTextAligned(stepName, labelX, labelY, TextAlignment.LEFT);

                    // 繪製簽名區域 (在標籤右側)
                    float signatureX = currentX + labelWidth + labelMargin;
                    for (String name : userNames) {
                        String userName = name.trim();
                        Rectangle rect = new Rectangle(signatureX, currentY, fieldWidth, fieldHeight);

                        // 創建簽名域
                        // **重要**: 為了確保每個簽名域的唯一性,欄位名稱需要加上頁碼
                        PdfSignatureFormField sigField = PdfSignatureFormField.createSignature(pdfDoc, rect);
                        sigField.setFieldName(userName + "_page_" + i);

                        // 假設此方法設置簽名欄位樣式
                        setupFieldStyle(sigField);

                        // 將簽名域添加到當前頁面
                        form.addField(sigField, currentPage);

                        signatureX += fieldWidth + userSpacing;
                    }

                    // 減去組間距,為下一個組準備位置
                    currentX -= groupSpacing;
                }
            }
            // =================================================================

            pdfDoc.close();
            return outputStream.toByteArray();
        } catch (Exception e) {
            // log.error("為每一頁添加簽名域失敗", e);
            e.printStackTrace(); // 簡單打印錯誤堆棧
            // 確保資源被關閉
            // closeResources(pdfDoc, reader, writer, outputStream);
            return pdfBytes; // 出錯時返回原始文件
        }
    }
    /**
     * 按stepName分组并合并用户
     */
    private static List<SignGroup> groupSignUsers(List<SignUser> users) {
        // 使用LinkedHashMap保持插入顺序
        Map<String, SignGroup> groupMap = new LinkedHashMap<>();
        for (SignUser user : users) {
            String stepName = user.getStepName();
            SignGroup group = groupMap.get(stepName);
            if (group == null) {
                // 新组,以当前用户的序号创建组
                group = new SignGroup(user.getSort(), stepName);
                groupMap.put(stepName, group);
            }
            group.addUserNames(user.getFileId());
        }
        return new ArrayList<>(groupMap.values());
    }

    /**
     * 签名节点组类
     */
    private static class SignGroup {
        private Integer sort;
        private String stepName;
        private List<String> userNames = new ArrayList<>();

        public SignGroup(Integer sort, String stepName) {
            this.sort = sort;
            this.stepName = stepName;
        }

        public void addUserNames(String names) {
            if (names != null) {
                Arrays.stream(names.split(","))
                        .map(String::trim)
                        .filter(s -> !s.isEmpty())
                        .forEach(userNames::add);
            }
        }

        public Integer getSort() {
            return sort;
        }

        public String getStepName() {
            return stepName;
        }

        public String[] getUserNames() {
            return userNames.toArray(new String[0]);
        }
    }


    /**
     * 设置签名域样式
     */
    private static void setupFieldStyle(PdfSignatureFormField field) {
        PdfAnnotation widget = field.getWidgets().get(0);
//        widget.setB(PdfAnnotation.STYLE_SOLID);
        widget.setColor(ColorConstants.BLACK);
        field.setBorderWidth(1);
//        widget.setH(PdfAnnotation.HIGHLIGHT_INVERT);
        field.setVisibility(PdfFormField.VISIBLE);
    }

    public static PdfFont createChineseFont() throws IOException {
        // 使用 try-with-resources 語句確保 InputStream 會被自動關閉
        // 注意:這裡使用 FontUtil.class 來獲取 ClassLoader
        try (InputStream fontStream = FontUtil.class.getClassLoader().getResourceAsStream(FONT_BASE_PATH)) {
            // 關鍵檢查:如果 fontStream 為 null,表示在 classpath 中找不到資源
            if (fontStream == null) {
                throw new IOException("無法找到字體檔案: '" + FONT_BASE_PATH + "'。請確認該檔案位於您專案的資源路徑 (classpath) 下。");
            }
            // 因為 iText 可能需要多次讀取字體數據,所以先將流轉換為 byte[]
            byte[] fontBytes = toByteArray(fontStream);
            // 使用 byte[] 創建字體
            // PdfEncodings.IDENTITY_H 是處理 CJK (中日韓) 字符的關鍵
            return PdfFontFactory.createFont(
                    fontBytes,
                    PdfEncodings.IDENTITY_H,
                    PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED);
        }
    }

    /**
     * 輔助方法:將 InputStream 的內容完整讀取到一個 byte 陣列中。
     *
     * @param input 輸入流
     * @return 包含輸入流所有數據的 byte 陣列
     * @throws IOException 如果讀取流時發生錯誤
     */
    private static byte[] toByteArray(InputStream input) throws IOException {
        try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
            int nRead;
            byte[] data = new byte[4096]; // 使用 4KB 的緩衝區
            while ((nRead = input.read(data, 0, data.length)) != -1) {
                buffer.write(data, 0, nRead);
            }
            return buffer.toByteArray();
        }
    }

    /**
     * 关闭资源
     */
    private static void closeResources(PdfDocument pdfDoc, PdfReader reader,
                                       PdfWriter writer, OutputStream os) {
        try {
            if (pdfDoc != null) pdfDoc.close();
            if (reader != null) reader.close();
            if (writer != null) writer.close();
            if (os != null) os.close();
        } catch (Exception e) {
            log.error("资源关闭失败", e);
        }
    }

    /**
     * 签名用户信息类
     */
    @Data
    private static class SignUser {
        private Integer sort;
        private String fileId;
        private String stepName;
        private String userName;

    }


    /**
     * 解析签名用户列表
     *
     * @param data 原始数据对象
     * @return 签名用户列表
     */
    private static List<SignUser> parseSignUserList(Object data) {
        List<SignUser> signUserList = new ArrayList<>();

        if (data instanceof Map) {
            Map<?, ?> dataMap = (Map<?, ?>) data;
            Object signUserListObj = dataMap.get("signUserList");

            if (signUserListObj instanceof List) {
                List<?> signUserObjList = (List<?>) signUserListObj;

                for (Object signUserObj : signUserObjList) {
                    if (signUserObj instanceof SignNode) {
                        SignNode signUserMap = (SignNode) signUserObj;
                        SignUser signUser = new SignUser();
                        String fileIds = signUserMap.getProcesspreparetionId();
                        Integer flag = signUserMap.getFlag();
                        if (fileIds != null) {
                            signUser.setFileId(fileIds);
                        }
                        signUser.setSort(flag);
                        // 获取stepName
                        String stepNameObj = signUserMap.getStepName();
                        if (stepNameObj != null) {
                            signUser.setStepName(stepNameObj);
                        }

                        // 获取userName
                        String userNameObj = signUserMap.getUserName();
                        if (userNameObj != null) {
                            signUser.setUserName(userNameObj);
                        }
                        signUserList.add(signUser);
                    }
                }
            }
        }

        return signUserList;
    }
}

3. 效果展示:

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐