Excel模板智能转PDF:零硬编码的通用打印解决方案

Excel模板智能转PDF:零硬编码的通用打印解决方案

从200行硬编码到1200行通用框架,一次重构彻底解决企业报表打印难题

背景痛点

在企业级应用中,Excel报表转PDF打印是一个高频需求。传统实现往往存在以下问题:

  1. 硬编码严重:每个报表都需要单独写一套转换逻辑,数据行位置、表头行数、边框样式全部写死
  2. 维护成本高:更换模板格式需要修改大量Java代码
  3. 条码支持弱:条形码/二维码生成与插入逻辑分散,难以复用
  4. 分页处理复杂:表头重复、页脚合并区域错位等问题频发

本文介绍的 ExcelPdfPrintService 通过模板自动扫描 + 动态布局检测,实现了真正的零硬编码通用打印方案。


核心设计理念

1. 模板即配置

所有布局信息(数据行位置、表头行数、边框策略、页脚合并区域)均从Excel模板自动检测,无需在代码中硬编码任何行列号。

java 复制代码
// ❌ 传统方式:硬编码
int dataStartRow = 5;  // 第5行开始是数据
int headerRowCount = 5; // 前5行是表头

// ✅ 本方案:自动检测
TemplateLayout layout = scanTemplate(templateBytes);
log.info("数据行起始: {}, 表头行数: {}", layout.getDataStartRow(), layout.getHeaderRowCount());

2. 占位符约定

模板使用两种占位符:

  • {xxx} - 全局单值占位符(如标题、日期等)
  • {list.fieldName} - 列表数据占位符(EasyExcel FillWrapper匹配)

扫描器通过正则表达式 \{list\.(\w+)\} 自动定位数据行起始位置。

3. 三步走架构

复制代码
加载模板 → EasyExcel填充 → POI插入条码 → iText构建PDF
   ↓              ↓              ↓              ↓
自动检测布局   填充数据行    生成条码图片   重建表格+分页

关键技术实现

一、模板布局自动扫描

这是整个方案的核心,通过一次遍历完成所有元数据采集:

java 复制代码
private TemplateLayout scanTemplate(byte[] templateBytes) {
    TemplateLayout layout = new TemplateLayout();
    
    try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(templateBytes))) {
        XSSFSheet sheet = wb.getSheetAt(0);
        
        // 1. 扫描 {list.xxx} 找到数据行起始位置
        int dataRow = -1;
        for (int r = 0; r <= sheet.getLastRowNum(); r++) {
            XSSFRow row = sheet.getRow(r);
            for (int c = 0; c < row.getLastCellNum(); c++) {
                XSSFCell cell = row.getCell(c);
                Matcher m = LIST_FIELD_PATTERN.matcher(cell.getStringCellValue());
                if (m.find()) {
                    if (dataRow == -1) dataRow = r;
                    layout.getFieldColMap().put(m.group(1), c);
                }
            }
        }
        
        layout.setDataStartRow(dataRow);
        layout.setHeaderRowCount(dataRow); // 数据行之前全是表头
        layout.setColHeaderRow(dataRow - 1); // 列标题行
        
        // 2. 记录页脚合并区域偏移量
        int footerStart = dataRow + 1;
        for (CellRangeAddress range : sheet.getMergedRegions()) {
            if (range.getFirstRow() >= footerStart) {
                int rowOffset = range.getFirstRow() - footerStart;
                layout.getFooterMergeOffsets().add(new int[]{
                    rowOffset, 
                    range.getLastRow() - footerStart,
                    range.getFirstColumn(), 
                    range.getLastColumn()
                });
            }
        }
        
        // 3. 检测Logo图片锚点
        detectLogoAnchor(sheet, dataRow, layout);
    }
    
    return layout;
}

关键洞察

  • 数据行之前的所有行 = 表头区域(PDF分页时自动重复)
  • 数据行本身 = 列标题行(与数据一起强制显示边框)
  • 数据行之后的合并区域 = 页脚(需动态修正行号)

二、条码/二维码智能插入

支持CODE_128条形码和QR二维码,自动填满合并单元格:

java 复制代码
public enum CodeType {
    BARCODE,  // 使用 BarCodeUtils 生成
    QRCODE    // 使用 CodeUtils 生成
}

@Data
public static class CodeColumnConfig {
    private String fieldName;     // 字段名(对应模板中的{list.xxx})
    private CodeType codeType;    // 条码类型
    private int width;            // 生成图片宽度
    private int height;           // 生成图片高度
    private float renderScale;    // PDF渲染缩放比例(1.0=填满单元格)
}

插入流程

java 复制代码
private void insertCodeImages(XSSFSheet sheet, int dataSize, 
                              List<CodeColumnConfig> codeColumns,
                              TemplateLayout layout) {
    XSSFDrawing drawing = sheet.createDrawingPatriarch();
    
    for (CodeColumnConfig cfg : codeColumns) {
        Integer col = layout.getFieldColMap().get(cfg.getFieldName());
        
        for (int r = layout.getDataStartRow(); r < layout.getDataStartRow() + dataSize; r++) {
            Row row = sheet.getRow(r);
            Cell cell = row.getCell(col);
            String text = getCellText(cell);
            
            // 生成条码图片
            BufferedImage img;
            if (cfg.getCodeType() == CodeType.QRCODE) {
                img = CodeUtils.createQRCode(text, cfg.getWidth());
            } else {
                img = BarCodeUtils.getBarCodeImage(text, cfg.getWidth(), cfg.getHeight());
            }
            
            // 插入到Excel(覆盖原占位符文本)
            byte[] imgBytes = toPngBytes(img);
            int imgIdx = wb.addPicture(imgBytes, Workbook.PICTURE_TYPE_PNG);
            
            // 查找合并区域,让图片填满整个区域
            CellRangeAddress mergeRange = findMergeRange(sheet, r, col);
            XSSFClientAnchor anchor = createAnchor(mergeRange);
            drawing.createPicture(anchor, imgIdx);
            
            cell.setCellValue(""); // 清除文本
        }
    }
}

renderScale的应用

对于某些场景(如标签打印),需要缩小二维码避免过于密集:

java 复制代码
// 配置时指定缩放比例
new CodeColumnConfig("qrCode", CodeType.QRCODE, 150, 150, 0.6f);

// PDF渲染时应用
if (codeCfg.getRenderScale() != 1.0f) {
    fitW *= codeCfg.getRenderScale();
    fitH *= codeCfg.getRenderScale();
}
img.scaleToFit(fitW, fitH);

三、PDF表格重建与分页

1. 边框策略(解决EasyExcel forceNewRow丢失边框问题)
java 复制代码
// ========== 边框策略 ==========
// 列标题行 ~ 数据末行:无条件强制 BOX 边框
if (r >= colHeaderRow && r <= dataEndRow) {
    pdfCell.setBorder(PdfPCell.BOX);
    pdfCell.setBorderWidth(0.5f);
} else if (cell == null || !hasBorder(cell)) {
    pdfCell.setBorder(0); // 其他区域按模板原始边框
}

为什么需要强制边框?

EasyExcel的 forceNewRow(true) 会在运行时动态插入新行,这些新行不继承模板的边框样式。因此必须在PDF层根据行区域判断强制添加。

2. 表头重复
java 复制代码
table.setHeaderRows(layout.getHeaderRowCount());

iText会自动在每页顶部重复指定的表头行数。

3. 页脚合并区域动态修正

由于EasyExcel插入了N条数据行,模板中定义的页脚合并区域行号会错位,需要根据偏移量重建:

java 复制代码
private void ensureFooterMerges(XSSFSheet sheet, int dataSize, TemplateLayout layout) {
    int footerStart = layout.getDataStartRow() + dataSize;
    
    // 移除错位的旧合并
    for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
        CellRangeAddress range = sheet.getMergedRegion(i);
        if (range.getFirstRow() >= footerStart) {
            sheet.removeMergedRegion(i);
        }
    }
    
    // 根据偏移量重建
    for (int[] offset : layout.getFooterMergeOffsets()) {
        int firstRow = footerStart + offset[0];
        int lastRow = footerStart + offset[1];
        sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, offset[2], offset[3]));
    }
}
4. 页码显示

使用iText的 PdfPageEventHelper 实现"第 X 页,共 Y 页":

java 复制代码
private static class PageNumberEvent extends PdfPageEventHelper {
    private PdfTemplate totalTemplate;
    
    @Override
    public void onOpenDocument(PdfWriter writer, Document document) {
        totalTemplate = writer.getDirectContent().createTemplate(50, 16);
    }
    
    @Override
    public void onEndPage(PdfWriter writer, Document document) {
        // 写入"第 N 页,共 "
        String prefix = "第 " + writer.getPageNumber() + " 页,共 ";
        cb.showText(prefix);
        cb.addTemplate(totalTemplate, startX + prefixWidth, y);
    }
    
    @Override
    public void onCloseDocument(PdfWriter writer, Document document) {
        // 文档结束时才知道总页数,回填到template
        totalTemplate.showText(writer.getPageNumber() + " 页");
    }
}

高级功能:标签打印模式

除了常规表格打印,还支持每页一个标签的模式(如物料标签、条码标签):

java 复制代码
// 每个Map生成一页PDF,标签尺寸100mm × 60mm
service.generateLabelPdf(
    "templates/label.xlsx", 
    dataList, 
    "物料标签", 
    codeFields,
    100f,  // 宽度毫米
    60f    // 高度毫米
);

实现原理

  1. 逐条数据调用 buildSingleLabelPdf() 生成单页PDF字节数组
  2. 使用 PdfCopy 合并所有单页到一个文档
  3. 每页独立设置页面尺寸为标签纸大小
java 复制代码
ByteArrayOutputStream mergedOut = new ByteArrayOutputStream();
Document mergedDoc = new Document();
PdfCopy pdfCopy = new PdfCopy(mergedDoc, mergedOut);
mergedDoc.open();

for (Map<String, String> data : dataList) {
    byte[] singlePdf = buildSingleLabelPdf(templateBytes, data, ...);
    PdfReader reader = new PdfReader(singlePdf);
    pdfCopy.addPage(pdfCopy.getImportedPage(reader, 1));
    reader.close();
}

mergedDoc.close();

性能优化要点

1. ZipSecureFile防溢出

POI处理大文件时会检查压缩比,默认阈值可能导致异常:

java 复制代码
ZipSecureFile.setMinInflateRatio(0);  // 处理前禁用检查
try {
    // POI操作
} finally {
    ZipSecureFile.setMinInflateRatio(0.01);  // 处理后恢复
}

2. 图片预提取

避免在PDF构建循环中重复读取图片:

java 复制代码
Map<String, byte[]> pictureMap = extractPictures(sheet);
// 后续直接 pictureMap.get(row + "," + col)

3. 合并区域HashMap索引

将合并区域转为 Map<"row,col", int[rowSpan, colSpan]>,查询从O(n)降为O(1):

java 复制代码
Map<String, int[]> mergeSpanMap = new HashMap<>();
Set<String> mergedSkipSet = new HashSet<>();

for (CellRangeAddress range : sheet.getMergedRegions()) {
    mergeSpanMap.put(range.getFirstRow() + "," + range.getFirstColumn(), 
                     new int[]{lr - fr + 1, lc - fc + 1});
    // 标记非左上角单元格为跳过
    for (int r = fr; r <= lr; r++) {
        for (int c = fc; c <= lc; c++) {
            if (r != fr || c != fc) mergedSkipSet.add(r + "," + c);
        }
    }
}

使用示例

基础用法(无条码)

java 复制代码
List<Map<String, String>> dataList = List.of(
    Map.of("name", "张三", "age", "25", "dept", "技术部"),
    Map.of("name", "李四", "age", "30", "dept", "市场部")
);

Map<String, Object> extraParams = Map.of(
    "title", "员工花名册",
    "date", "2024-01-01",
    "logo", base64String  // 可选
);

ResponseEntity<InputStreamResource> pdf = service.generatePdf(
    "templates/employee.xlsx",
    dataList,
    extraParams,
    "员工花名册"
);

带条码/二维码

java 复制代码
List<CodeColumnConfig> codes = List.of(
    new CodeColumnConfig("barcode", CodeType.BARCODE, 300, 80),
    new CodeColumnConfig("qrcode", CodeType.QRCODE, 150, 150, 0.8f)
);

ResponseEntity<InputStreamResource> pdf = service.generatePdf(
    "templates/product.xlsx",
    dataList,
    extraParams,
    "产品清单",
    codes,
    true  // 显示页码
);

标签打印

java 复制代码
List<CodeColumnConfig> labelCodes = List.of(
    new CodeColumnConfig("materialCode", CodeType.BARCODE),
    new CodeColumnConfig("qrUrl", CodeType.QRCODE, 100, 100, 0.7f)
);

ResponseEntity<InputStreamResource> pdf = service.generateLabelPdf(
    "templates/label.xlsx",
    materialList,
    "物料标签",
    labelCodes,
    100f,  // 100mm宽
    60f    // 60mm高
);

完整代码

java 复制代码
package org.springblade.common.excel;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.alibaba.excel.write.metadata.fill.FillWrapper;
import com.itextpdf.text.BaseColor;
import com.itextpdf.text.Document;
import com.itextpdf.text.Element;
import com.itextpdf.text.Image;
import com.itextpdf.text.PageSize;
import com.itextpdf.text.Phrase;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.BaseFont;
import com.itextpdf.text.pdf.PdfContentByte;
import com.itextpdf.text.pdf.PdfCopy;
import com.itextpdf.text.pdf.PdfPCell;
import com.itextpdf.text.pdf.PdfPageEventHelper;
import com.itextpdf.text.pdf.PdfPTable;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfTemplate;
import com.itextpdf.text.pdf.PdfWriter;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ooxml.POIXMLDocumentPart;
import org.apache.poi.openxml4j.util.ZipSecureFile;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.ClientAnchor;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFCell;
import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFPicture;
import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFShape;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springblade.common.utils.BarCodeUtils;
import org.springblade.common.utils.CodeUtils;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Excel模板PDF打印服务(通用版)
 * <p>
 * 基于 EasyExcel 模板填充 + 内存直转 PDF。
 * <b>完全通用</b>------所有布局信息(数据行位置、表头行数、边框策略、页脚合并区域)
 * 均从模板自动检测,更换模板无需改代码。
 * <p>
 * 模板占位符约定:
 * <ul>
 *   <li>{xxx}       全局单值占位符</li>
 *   <li>{list.xxx}  列表数据占位符,通过 FillWrapper("list") 匹配</li>
 * </ul>
 * <p>
 * 自动检测逻辑:
 * <ol>
 *   <li>扫描模板找到 {list.xxx} 所在行 → dataStartRow</li>
 *   <li>dataStartRow 之前的行 → 表头区域(PDF分页自动重复)</li>
 *   <li>dataStartRow - 1 → 列标题行(与数据行一起强制显示边框)</li>
 *   <li>dataStartRow 之后的模板行 → 页脚区域,记录其合并区域偏移量</li>
 * </ol>
 *
 * @see BarCodeUtils
 * @see CodeUtils
 */
@Slf4j
@Service
public class ExcelPdfPrintService {

	/** 匹配 {list.fieldName} 占位符 */
	private static final Pattern LIST_FIELD_PATTERN = Pattern.compile("\\{list\\.(\\w+)}");

	// ==================== 模板布局元数据 ====================

	/**
	 * 从模板自动检测的布局信息,所有行号/列范围/边框策略全在这里,零硬编码。
	 */
	@Data
	private static class TemplateLayout {
		/** 数据行起始位置(0-based),由 {list.xxx} 自动检测 */
		int dataStartRow;

		/** PDF分页时重复的表头行数(= dataStartRow) */
		int headerRowCount;

		/** 列标题行号(= dataStartRow - 1),与数据行一起强制显示边框 */
		int colHeaderRow;

		/** 字段名 → 列号(0-based) 映射(供条码定位+数据行识别) */
		Map<String, Integer> fieldColMap;

		/** Logo图片在模板中的锚点区域,null表示无Logo */
		int[] logoAnchor;

		/**
		 * 模板中数据行之后的合并区域,记录为偏移量。
		 * 每个 int[4] = {rowOffset, rowSpanOffset, firstCol, lastCol}
		 * 填充后实际行 = dataStartRow + dataSize + rowOffset
		 */
		List<int[]> footerMergeOffsets;
	}

	// ==================== 条码/二维码配置 ====================

	/** 条码类型枚举 */
	public enum CodeType {
		/** CODE_128 条形码(使用 BarCodeUtils) */
		BARCODE,
		/** 二维码(使用 CodeUtils) */
		QRCODE
	}

	/**
	 * 条码列配置
	 */
	@Data
	public static class CodeColumnConfig {
		private String fieldName;
		private CodeType codeType;
		private int width;
		private int height;
		/**
		 * PDF中渲染缩放比例,1.0 = 填满整个合并单元格区域。
		 * 小于1.0时图片按比例缩小(例如0.5 = 缩小到50%)。
		 */
		private float renderScale = 1.0f;

		public CodeColumnConfig(String fieldName, CodeType codeType) {
			this.fieldName = fieldName;
			this.codeType = codeType;
			this.renderScale = 1.0f;
			if (codeType == CodeType.QRCODE) {
				this.width = 150;
				this.height = 150;
			} else {
				this.width = 300;
				this.height = 80;
			}
		}

		public CodeColumnConfig(String fieldName, CodeType codeType, int width, int height) {
			this.fieldName = fieldName;
			this.codeType = codeType;
			this.width = width;
			this.height = height;
			this.renderScale = 1.0f;
		}

		public CodeColumnConfig(String fieldName, CodeType codeType, int width, int height, float renderScale) {
			this.fieldName = fieldName;
			this.codeType = codeType;
			this.width = width;
			this.height = height;
			this.renderScale = renderScale;
		}
	}

	// ==================== 公开 API ====================

	/**
	 * 生成PDF(简洁版:无条码,默认显示页脚页码)
	 *
	 * @param templatePath classpath 下的模板路径,如 "templates/xxx.xlsx"
	 * @param dataList     数据列表,每条记录是 Map(key对应模板中{list.xxx}的xxx)
	 * @param extraParams  全局参数(key对应模板中{xxx}的xxx,含logo的Base64等)
	 * @param fileName     输出文件名(不含后缀)
	 */
	public ResponseEntity<InputStreamResource> generatePdf(
		String templatePath,
		List<Map<String, String>> dataList,
		Map<String, Object> extraParams,
		String fileName) throws Exception {
		return generatePdf(templatePath, dataList, extraParams, fileName, null, true);
	}

	/**
	 * 生成PDF(带条码版)
	 */
	public ResponseEntity<InputStreamResource> generatePdf(
		String templatePath,
		List<Map<String, String>> dataList,
		Map<String, Object> extraParams,
		String fileName,
		List<CodeColumnConfig> codeColumns) throws Exception {
		return generatePdf(templatePath, dataList, extraParams, fileName, codeColumns, true);
	}

	/**
	 * 生成PDF(完整参数版本)
	 *
	 * @param templatePath   classpath 下的模板路径,如 "templates/xxx.xlsx"
	 * @param dataList       数据列表
	 * @param extraParams    全局参数(含logo的Base64等)
	 * @param fileName       输出文件名(不含后缀)
	 * @param codeColumns    条码列配置,null或空表示不生成条码
	 * @param showPageFooter 是否在每页底部显示 "第 X 页,共 Y 页"
	 * @return PDF响应
	 */
	public ResponseEntity<InputStreamResource> generatePdf(
		String templatePath,
		List<Map<String, String>> dataList,
		Map<String, Object> extraParams,
		String fileName,
		List<CodeColumnConfig> codeColumns,
		boolean showPageFooter) throws Exception {

		long start = System.currentTimeMillis();

		// 0. 加载模板 + 自动检测布局
		byte[] templateBytes = loadTemplateBytes(templatePath);
		TemplateLayout layout = scanTemplate(templateBytes);
		log.info("模板布局检测完成: dataStartRow={}, headerRowCount={}, colHeaderRow={}, 页脚合并区域={}个",
			layout.getDataStartRow(), layout.getHeaderRowCount(),
			layout.getColHeaderRow(),
			layout.getFooterMergeOffsets().size());

		// 1. EasyExcel 模板填充
		byte[] excelBytes = fillTemplate(templateBytes, dataList, extraParams);
		log.info("EasyExcel模板填充完成, 耗时: {}ms", System.currentTimeMillis() - start);

		// 2. PDF构建
		long pdfStart = System.currentTimeMillis();
		byte[] pdfBytes = buildPdf(excelBytes, extraParams, dataList.size(),
			codeColumns, layout, showPageFooter);
		log.info("PDF构建完成, 耗时: {}ms, 总耗时: {}ms",
			System.currentTimeMillis() - pdfStart, System.currentTimeMillis() - start);

		// 3. 返回PDF响应
		ByteArrayInputStream bais = new ByteArrayInputStream(pdfBytes);
		return ResponseEntity.ok()
			.header("Content-Disposition", "attachment; filename=" +
				URLEncoder.encode(fileName + ".pdf", StandardCharsets.UTF_8))
			.contentType(MediaType.APPLICATION_PDF)
			.body(new InputStreamResource(bais));
	}

	// ==================== 标签打印 API(每个数据对象 = 一页) ====================

	/**
	 * 标签打印:每个数据对象生成一页 PDF(如物料标签、条码标签等)。
	 * <p>
	 * 使用默认标签尺寸 100mm × 60mm。如需自定义尺寸,请使用带 labelWidthMm/labelHeightMm 参数的重载方法。
	 *
	 * @param templatePath classpath 下的模板路径
	 * @param dataList     数据列表,每个 Map 对应一页标签
	 * @param fileName     输出文件名(不含后缀)
	 * @param codeFields   条码/二维码字段配置,null表示不生成条码
	 * @return PDF响应
	 */
	public ResponseEntity<InputStreamResource> generateLabelPdf(
		String templatePath,
		List<Map<String, String>> dataList,
		String fileName,
		List<CodeColumnConfig> codeFields) throws Exception {
		return generateLabelPdf(templatePath, dataList, fileName, codeFields, 100f, 60f);
	}

	/**
	 * 标签打印:每个数据对象生成一页 PDF(如物料标签、条码标签等)。
	 * <p>
	 * 模板中使用 {xxx} 占位符(不需要 {list.xxx}),每页独立填充。
	 * 支持条形码和二维码------在 codeFields 中指定字段名和类型,
	 * 该字段值会被生成为图片插入到单元格中。
	 * <p>
	 * 用法示例:
	 * <pre>
	 * // 条码字段配置
	 * List&lt;CodeColumnConfig&gt; codes = List.of(
	 *     new CodeColumnConfig("tm", CodeType.BARCODE),
	 *     new CodeColumnConfig("qrCodeUrl", CodeType.QRCODE)
	 * );
	 * // 每个 map 就是一页标签,标签纸 100mm × 60mm
	 * service.generateLabelPdf("templates/label.xlsx", dataList, "标签打印", codes, 100f, 60f);
	 * </pre>
	 *
	 * @param templatePath  classpath 下的模板路径
	 * @param dataList      数据列表,每个 Map 对应一页标签
	 * @param fileName      输出文件名(不含后缀)
	 * @param codeFields    条码/二维码字段配置,null表示不生成条码
	 * @param labelWidthMm  标签纸宽度(毫米)
	 * @param labelHeightMm 标签纸高度(毫米)
	 * @return PDF响应
	 */
	public ResponseEntity<InputStreamResource> generateLabelPdf(
		String templatePath,
		List<Map<String, String>> dataList,
		String fileName,
		List<CodeColumnConfig> codeFields,
		float labelWidthMm,
		float labelHeightMm) throws Exception {

		long start = System.currentTimeMillis();

		if (dataList == null || dataList.isEmpty()) {
			throw new IllegalArgumentException("标签数据列表不能为空");
		}

		byte[] templateBytes = loadTemplateBytes(templatePath);

		// 扫描模板中 {xxx} 占位符的位置(字段名 → 列号),供条码定位
		Map<String, int[]> placeholderPositions = scanPlaceholderPositions(templateBytes);

		// 扫描模板中哪些行有边框(EasyExcel填充后边框会丢失,需要在PDF中强制恢复)
		Set<Integer> borderedRows = scanTemplateBorderedRows(templateBytes);

		// 逐个标签生成单页PDF,再合并
		ByteArrayOutputStream mergedOut = new ByteArrayOutputStream();
		Document mergedDoc = new Document();
		PdfCopy pdfCopy = new PdfCopy(mergedDoc, mergedOut);
		mergedDoc.open();

		BaseFont baseFont = createChineseBaseFont();

		for (Map<String, String> data : dataList) {
			byte[] singlePdf = buildSingleLabelPdf(templateBytes, data, placeholderPositions,
				codeFields, baseFont, borderedRows, labelWidthMm, labelHeightMm);
			PdfReader reader = new PdfReader(singlePdf);
			pdfCopy.addPage(pdfCopy.getImportedPage(reader, 1));
			reader.close();
		}

		mergedDoc.close();
		log.info("标签PDF生成完成, 共{}页, 耗时: {}ms", dataList.size(), System.currentTimeMillis() - start);

		ByteArrayInputStream bais = new ByteArrayInputStream(mergedOut.toByteArray());
		return ResponseEntity.ok()
			.header("Content-Disposition", "attachment; filename=" +
				URLEncoder.encode(fileName + ".pdf", StandardCharsets.UTF_8))
			.contentType(MediaType.APPLICATION_PDF)
			.body(new InputStreamResource(bais));
	}

	/**
	 * 扫描模板中 {xxx} 占位符的位置 → Map&lt;字段名, int[]{row, col}&gt;
	 */
	private Map<String, int[]> scanPlaceholderPositions(byte[] templateBytes) {
		Map<String, int[]> map = new HashMap<>();
		Pattern pattern = Pattern.compile("\\{(\\w+)}");
		ZipSecureFile.setMinInflateRatio(0);
		try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(templateBytes))) {
			XSSFSheet sheet = wb.getSheetAt(0);
			for (int r = 0; r <= sheet.getLastRowNum(); r++) {
				XSSFRow row = sheet.getRow(r);
				if (row == null) continue;
				for (int c = 0; c < row.getLastCellNum(); c++) {
					XSSFCell cell = row.getCell(c);
					if (cell == null || cell.getCellType() != CellType.STRING) continue;
					Matcher m = pattern.matcher(cell.getStringCellValue());
					if (m.find()) {
						map.put(m.group(1), new int[]{r, c});
					}
				}
			}
		} catch (Exception e) {
			log.warn("扫描模板占位符位置失败: {}", e.getMessage());
		} finally {
			ZipSecureFile.setMinInflateRatio(0.01);
		}
		return map;
	}

	/**
	 * 扫描模板中哪些行有边框。
	 * EasyExcel 填充后边框样式会丢失,需要预先记录,在生成 PDF 时强制恢复。
	 */
	private Set<Integer> scanTemplateBorderedRows(byte[] templateBytes) {
		Set<Integer> rows = new HashSet<>();
		ZipSecureFile.setMinInflateRatio(0);
		try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(templateBytes))) {
			XSSFSheet sheet = wb.getSheetAt(0);
			for (int r = 0; r <= sheet.getLastRowNum(); r++) {
				Row row = sheet.getRow(r);
				if (row == null) continue;
				for (int c = 0; c < row.getLastCellNum(); c++) {
					Cell cell = row.getCell(c);
					if (cell != null && hasBorder(cell)) {
						rows.add(r);
						break;
					}
				}
			}
		} catch (Exception e) {
			log.warn("扫描模板边框行失败: {}", e.getMessage(), e);
		} finally {
			ZipSecureFile.setMinInflateRatio(0.01);
		}
		log.info("模板边框行扫描结果: {}", rows);
		return rows;
	}

	/**
	 * 为单个标签生成一页PDF字节数组
	 */
	private byte[] buildSingleLabelPdf(byte[] templateBytes, Map<String, String> data,
										Map<String, int[]> placeholderPositions,
										List<CodeColumnConfig> codeFields,
										BaseFont baseFont,
										Set<Integer> borderedRows,
										float labelWidthMm,
										float labelHeightMm) throws Exception {
		// 1. EasyExcel 填充单条数据
		ByteArrayOutputStream excelOut = new ByteArrayOutputStream();
		try (ExcelWriter writer = EasyExcel.write(excelOut)
			.withTemplate(new ByteArrayInputStream(templateBytes)).build()) {
			WriteSheet sheet = EasyExcel.writerSheet().build();
			writer.fill(data, sheet);
		}
		byte[] excelBytes = excelOut.toByteArray();

		// 2. POI 打开 → 插入条码/二维码图片
		ZipSecureFile.setMinInflateRatio(0);
		try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(excelBytes))) {
			XSSFSheet sheet = wb.getSheetAt(0);

			// 插入条码/二维码
			if (codeFields != null && !codeFields.isEmpty()) {
				XSSFDrawing drawing = sheet.createDrawingPatriarch();
				for (CodeColumnConfig cfg : codeFields) {
					int[] pos = placeholderPositions.get(cfg.getFieldName());
					if (pos == null) continue;
					String text = data.get(cfg.getFieldName());
					if (text == null || text.isEmpty()) continue;

					try {
						BufferedImage img;
						if (cfg.getCodeType() == CodeType.QRCODE) {
							img = CodeUtils.createQRCode(text, cfg.getWidth());
						} else {
							img = BarCodeUtils.getBarCodeImage(text, cfg.getWidth(), cfg.getHeight());
						}
						if (img == null) continue;

						ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
						ImageIO.write(img, "PNG", imgOut);
						int idx = wb.addPicture(imgOut.toByteArray(), Workbook.PICTURE_TYPE_PNG);

						// 查找该位置所在的合并区域,条码/二维码图片填满整个合并区域
						int r1 = pos[0], c1 = pos[1], r2 = pos[0] + 1, c2 = pos[1] + 1;
						for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
							CellRangeAddress range = sheet.getMergedRegion(i);
							if (range.getFirstRow() <= pos[0] && range.getLastRow() >= pos[0]
								&& range.getFirstColumn() <= pos[1] && range.getLastColumn() >= pos[1]) {
								r1 = range.getFirstRow();
								c1 = range.getFirstColumn();
								r2 = range.getLastRow() + 1;
								c2 = range.getLastColumn() + 1;
								break;
							}
						}

						XSSFClientAnchor anchor = new XSSFClientAnchor(0, 0, 0, 0, c1, r1, c2, r2);
						anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
						drawing.createPicture(anchor, idx);

						// 清除占位符文本
						Row row = sheet.getRow(pos[0]);
						if (row != null) {
							Cell cell = row.getCell(pos[1]);
							if (cell != null) cell.setCellValue("");
						}
					} catch (Exception e) {
						log.warn("标签条码生成失败, field={}: {}", cfg.getFieldName(), e.getMessage());
					}
				}
			}

			// 3. 预计算合并区域和图片 → 构建PdfPTable
			Map<String, int[]> mergeSpanMap = new HashMap<>();
			Set<String> mergedSkipSet = new HashSet<>();
			for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
				CellRangeAddress range = sheet.getMergedRegion(i);
				int fr = range.getFirstRow(), lr = range.getLastRow();
				int fc = range.getFirstColumn(), lc = range.getLastColumn();
				mergeSpanMap.put(fr + "," + fc, new int[]{lr - fr + 1, lc - fc + 1});
				for (int r = fr; r <= lr; r++) {
					for (int c = fc; c <= lc; c++) {
						if (r != fr || c != fc) mergedSkipSet.add(r + "," + c);
					}
				}
			}

			Map<String, byte[]> pictureMap = extractPictures(sheet);

			// 构建条码位置 → 配置映射(用于 renderScale 缩放)
			Map<String, CodeColumnConfig> codePositionMap = new HashMap<>();
			if (codeFields != null) {
				for (CodeColumnConfig cfg : codeFields) {
					int[] pos = placeholderPositions.get(cfg.getFieldName());
					if (pos == null) continue;
					// 找到包含该占位符的合并区域的左上角(即图片提取时的key)
					int pr = pos[0], pc = pos[1];
					for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
						CellRangeAddress range = sheet.getMergedRegion(i);
						if (range.isInRange(pos[0], pos[1])) {
							pr = range.getFirstRow();
							pc = range.getFirstColumn();
							break;
						}
					}
					codePositionMap.put(pr + "," + pc, cfg);
				}
			}

			float[] colWidths = getColWidthPercents(sheet);
			int colCount = colWidths.length;

			// 计算各列实际点数宽度(用于图片自适应缩放)
			float mmToPt = 72f / 25.4f;
			float pageWidthPt = labelWidthMm * mmToPt;
			float pageHeightPt = labelHeightMm * mmToPt;
			float margin = 10f;
			float contentWidth = pageWidthPt - 2 * margin;
			int totalRawColWidth = 0;
			for (int ci = 0; ci < colCount; ci++) totalRawColWidth += sheet.getColumnWidth(ci);
			float[] colPts = new float[colCount];
			for (int ci = 0; ci < colCount; ci++) {
				colPts[ci] = (float) sheet.getColumnWidth(ci) / totalRawColWidth * contentWidth;
			}

			PdfPTable table = new PdfPTable(colWidths);
			table.setWidthPercentage(100);

			// 边框行检测防护:如果模板扫描没有找到边框行,从填充后的内容检测
			// (含中文标签文字的行 = 数据行 = 需要表格线)
			if (borderedRows.isEmpty()) {
				for (int r = 0; r <= sheet.getLastRowNum(); r++) {
					Row checkRow = sheet.getRow(r);
					if (checkRow == null) continue;
					for (int c = 0; c < Math.min(checkRow.getLastCellNum(), colCount); c++) {
						Cell checkCell = checkRow.getCell(c);
						if (checkCell != null && checkCell.getCellType() == CellType.STRING) {
							String text = checkCell.getStringCellValue().trim();
							if (text.matches(".*[\\u4e00-\\u9fff].*")) {
								borderedRows.add(r);
								break;
							}
						}
					}
				}
				log.info("标签边框行通过内容检测: {}", borderedRows);
			}

			for (int r = 0; r <= sheet.getLastRowNum(); r++) {
				Row row = sheet.getRow(r);
				for (int c = 0; c < colCount; c++) {
					String key = r + "," + c;
					if (mergedSkipSet.contains(key)) continue;

					Cell cell = (row != null) ? row.getCell(c) : null;
					PdfPCell pdfCell;

					byte[] picData = pictureMap.get(key);
					if (picData != null) {
						Image img = Image.getInstance(picData);
						// 根据实际合并区域计算图片尺寸
						int[] picSpan = mergeSpanMap.get(key);
						float fitW = colPts[c];
						float fitH = (row != null) ? row.getHeightInPoints() : 15f;
						if (picSpan != null) {
							fitW = 0;
							for (int sc = c; sc < Math.min(c + picSpan[1], colCount); sc++) fitW += colPts[sc];
							fitH = 0;
							for (int sr = r; sr < r + picSpan[0]; sr++) {
								Row rw = sheet.getRow(sr);
								fitH += (rw != null) ? rw.getHeightInPoints() : 15f;
							}
						}
						// 应用 renderScale 缩放(用于缩小二维码等)
						CodeColumnConfig codeCfg = codePositionMap.get(key);
						if (codeCfg != null && codeCfg.getRenderScale() != 1.0f) {
							fitW *= codeCfg.getRenderScale();
							fitH *= codeCfg.getRenderScale();
						}
						img.scaleToFit(fitW, fitH);
						pdfCell = new PdfPCell(img);
					} else {
						String value = getCellText(cell);
						Font excelFont = getCellFont(cell);
						float fontSize = (excelFont != null) ? excelFont.getFontHeightInPoints() : 9f;
						int fontStyle = (excelFont != null && excelFont.getBold())
							? com.itextpdf.text.Font.BOLD : 0;
						com.itextpdf.text.Font pdfFont = new com.itextpdf.text.Font(
							baseFont, fontSize, fontStyle, BaseColor.BLACK);
						pdfCell = new PdfPCell(new Phrase(value, pdfFont));
					}

					// 边框:根据原始模板扫描结果强制恢复(EasyExcel填充后边框丢失)
					if (borderedRows.contains(r)) {
						pdfCell.setBorder(PdfPCell.BOX);
						pdfCell.setBorderWidth(0.5f);
					} else if (cell == null || !hasBorder(cell)) {
						pdfCell.setBorder(0);
					}
					if (cell != null) {
						pdfCell.setHorizontalAlignment(
							horAlign(cell.getCellStyle().getAlignment().getCode()));
						pdfCell.setVerticalAlignment(
							verAlign(cell.getCellStyle().getVerticalAlignment().getCode()));
					}
					pdfCell.setMinimumHeight(row != null ? row.getHeightInPoints() : 15f);

					int[] span = mergeSpanMap.get(key);
					if (span != null) {
						pdfCell.setRowspan(span[0]);
						pdfCell.setColspan(span[1]);
					}

					table.addCell(pdfCell);
				}
			}

			// 4. 输出单页PDF(标签尺寸: 140mm × 80mm)
			ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
			Rectangle labelSize = new Rectangle(pageWidthPt, pageHeightPt);
			Document doc = new Document(labelSize);
			doc.setMargins(margin, margin, margin, margin);
			PdfWriter.getInstance(doc, pdfOut);
			doc.open();
			doc.add(table);
			doc.close();

			return pdfOut.toByteArray();
		} finally {
			ZipSecureFile.setMinInflateRatio(0.01);
		}
	}

	// ==================== 模板扫描(核心:自动检测所有布局信息) ====================

	/**
	 * 扫描模板,自动检测所有布局信息:
	 * <ul>
	 *   <li>找到 {list.xxx} 所在行 → dataStartRow</li>
	 *   <li>dataStartRow - 1 → colHeaderRow</li>
	 *   <li>dataStartRow → headerRowCount (PDF分页重复行数)</li>
	 *   <li>检测数据行是否有边框</li>
	 *   <li>记录数据行之后的合并区域偏移量</li>
	 *   <li>检测Logo图片锚点</li>
	 * </ul>
	 */
	private TemplateLayout scanTemplate(byte[] templateBytes) {
		TemplateLayout layout = new TemplateLayout();
		layout.setFieldColMap(new HashMap<>());
		layout.setFooterMergeOffsets(new ArrayList<>());

		ZipSecureFile.setMinInflateRatio(0);
		try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(templateBytes))) {
			XSSFSheet sheet = wb.getSheetAt(0);

			// 1. 扫描所有行,找到含 {list.xxx} 的数据行
			int dataRow = -1;
			for (int r = 0; r <= sheet.getLastRowNum(); r++) {
				XSSFRow row = sheet.getRow(r);
				if (row == null) continue;
				for (int c = 0; c < row.getLastCellNum(); c++) {
					XSSFCell cell = row.getCell(c);
					if (cell == null || cell.getCellType() != CellType.STRING) continue;
					Matcher m = LIST_FIELD_PATTERN.matcher(cell.getStringCellValue());
					if (m.find()) {
						if (dataRow == -1) dataRow = r;
						layout.getFieldColMap().put(m.group(1), c);
					}
				}
				if (dataRow != -1 && r > dataRow) break; // 只需扫描数据行本身
			}

			if (dataRow == -1) {
				throw new IllegalStateException("模板中未找到 {list.xxx} 占位符,无法确定数据行位置");
			}

			layout.setDataStartRow(dataRow);
			layout.setHeaderRowCount(dataRow); // 数据行之前的所有行都是表头
			layout.setColHeaderRow(Math.max(0, dataRow - 1));

			// 2. 记录数据行之后的合并区域偏移量(模板中dataRow+1开始的行是页脚)
			int footerStart = dataRow + 1; // 模板中页脚起始行
			for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
				CellRangeAddress range = sheet.getMergedRegion(i);
				if (range.getFirstRow() >= footerStart) {
					int rowOffset = range.getFirstRow() - footerStart;
					int rowSpanOffset = range.getLastRow() - footerStart;
					layout.getFooterMergeOffsets().add(new int[]{
						rowOffset, rowSpanOffset,
						range.getFirstColumn(), range.getLastColumn()
					});
				}
			}

			// 3. 检测Logo图片锚点
			for (POIXMLDocumentPart part : sheet.getRelations()) {
				if (part instanceof XSSFDrawing drawing) {
					for (XSSFShape shape : drawing.getShapes()) {
						if (shape instanceof XSSFPicture pic) {
							XSSFClientAnchor anchor = (XSSFClientAnchor) pic.getAnchor();
							// Logo 通常在数据行之前
							if (anchor.getRow1() < dataRow) {
								layout.setLogoAnchor(new int[]{
									anchor.getCol1(), anchor.getRow1(),
									anchor.getCol2(), anchor.getRow2()
								});
								break;
							}
						}
					}
				}
			}

		} catch (IllegalStateException e) {
			throw e;
		} catch (Exception e) {
			throw new RuntimeException("模板扫描失败: " + e.getMessage(), e);
		} finally {
			ZipSecureFile.setMinInflateRatio(0.01);
		}

		return layout;
	}

	// ==================== 模板加载 ====================

	private byte[] loadTemplateBytes(String templatePath) throws IOException {
		try (InputStream is = getClass().getClassLoader().getResourceAsStream(templatePath)) {
			if (is == null) {
				throw new FileNotFoundException("XLSX模板未找到: " + templatePath);
			}
			return is.readAllBytes();
		}
	}

	// ==================== EasyExcel 模板填充 ====================

	private byte[] fillTemplate(byte[] templateBytes,
								List<Map<String, String>> dataList,
								Map<String, Object> extraParams) throws Exception {
		ByteArrayOutputStream out = new ByteArrayOutputStream();
		try (ExcelWriter writer = EasyExcel.write(out)
			.withTemplate(new ByteArrayInputStream(templateBytes)).build()) {
			WriteSheet sheet = EasyExcel.writerSheet().build();

			FillConfig listCfg = FillConfig.builder().forceNewRow(true).build();
			writer.fill(new FillWrapper("list", dataList), listCfg, sheet);

			Map<String, Object> globals = new HashMap<>(extraParams);
			globals.remove("logo");
			writer.fill(globals, sheet);
		}
		return out.toByteArray();
	}

	// ==================== PDF 构建 ====================

	private byte[] buildPdf(byte[] excelBytes, Map<String, Object> extraParams,
							int dataSize, List<CodeColumnConfig> codeColumns,
							TemplateLayout layout, boolean showPageFooter) throws Exception {
		ZipSecureFile.setMinInflateRatio(0);
		try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(excelBytes))) {
			XSSFSheet sheet = wb.getSheetAt(0);

			// A. 插入Logo图片
			insertLogo(sheet, extraParams, layout);

			// B. 插入条码/二维码图片
			insertCodeImages(sheet, dataSize, codeColumns, layout);

			// C. 修正页脚合并区域
			ensureFooterMerges(sheet, dataSize, layout);

			// D. 预计算合并区域 → HashMap
			Map<String, int[]> mergeSpanMap = new HashMap<>();
			Set<String> mergedSkipSet = new HashSet<>();
			for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
				CellRangeAddress range = sheet.getMergedRegion(i);
				int fr = range.getFirstRow(), lr = range.getLastRow();
				int fc = range.getFirstColumn(), lc = range.getLastColumn();
				mergeSpanMap.put(fr + "," + fc, new int[]{lr - fr + 1, lc - fc + 1});
				for (int r = fr; r <= lr; r++) {
					for (int c = fc; c <= lc; c++) {
						if (r != fr || c != fc) {
							mergedSkipSet.add(r + "," + c);
						}
					}
				}
			}

			// E. 预提取图片
			Map<String, byte[]> pictureMap = extractPictures(sheet);

			// F. 构建 PdfPTable
			float[] colWidths = getColWidthPercents(sheet);
			int colCount = colWidths.length;
			PdfPTable table = new PdfPTable(colWidths);
			table.setWidthPercentage(100);
			table.setHeaderRows(layout.getHeaderRowCount());

			BaseFont baseFont = createChineseBaseFont();

			// 行区域边界(自动计算)
			int dataStartRow = layout.getDataStartRow();
			int colHeaderRow = layout.getColHeaderRow();
			int dataEndRow = dataStartRow + dataSize - 1;             // 最后一条数据行
			int footerStart = dataStartRow + dataSize;                // 页脚起始行
			int lastRenderRow = sheet.getLastRowNum();                 // 渲染到Excel最后一行

			for (int r = 0; r <= lastRenderRow; r++) {
				Row row = sheet.getRow(r);
				for (int c = 0; c < colCount; c++) {
					String key = r + "," + c;
					if (mergedSkipSet.contains(key)) {
						continue;
					}

					Cell cell = (row != null) ? row.getCell(c) : null;
					PdfPCell pdfCell;

					// 图片单元格
					byte[] picData = pictureMap.get(key);
					if (picData != null) {
						Image img = Image.getInstance(picData);
						img.scaleToFit(160, 60);
						pdfCell = new PdfPCell(img);
					} else {
						String value = getCellText(cell);
						Font excelFont = getCellFont(cell);
						float fontSize = (excelFont != null) ? excelFont.getFontHeightInPoints() : 10f;
						int fontStyle = (excelFont != null && excelFont.getBold())
							? com.itextpdf.text.Font.BOLD : 0;
						com.itextpdf.text.Font pdfFont = new com.itextpdf.text.Font(
							baseFont, fontSize, fontStyle, BaseColor.BLACK);
						pdfCell = new PdfPCell(new Phrase(value, pdfFont));
					}

					// ========== 边框策略 ==========
					// 列标题行 ~ 数据末行:无条件强制 BOX 边框
					// (EasyExcel forceNewRow 生成的新行不继承边框样式,不能依赖模板检测)
					if (r >= colHeaderRow && r <= dataEndRow) {
						pdfCell.setBorder(PdfPCell.BOX);
						pdfCell.setBorderWidth(0.5f);
					} else if (r >= footerStart && r <= lastRenderRow) {
						// 页脚区域:读取单元格实际边框
						if (cell == null || !hasBorder(cell)) {
							pdfCell.setBorder(0);
						}
					} else if (r < colHeaderRow) {
						// 表头区域(Logo/标题等):读取单元格实际边框
						if (cell == null || !hasBorder(cell)) {
							pdfCell.setBorder(0);
						}
					}

					// 对齐方式
					if (cell != null) {
						pdfCell.setHorizontalAlignment(
							horAlign(cell.getCellStyle().getAlignment().getCode()));
						pdfCell.setVerticalAlignment(
							verAlign(cell.getCellStyle().getVerticalAlignment().getCode()));
					}
					pdfCell.setMinimumHeight(row != null ? row.getHeightInPoints() : 13f);

					// 合并跨度
					int[] span = mergeSpanMap.get(key);
					if (span != null) {
						pdfCell.setRowspan(span[0]);
						pdfCell.setColspan(span[1]);
					}

					table.addCell(pdfCell);
				}
			}

			// G. 输出 PDF(A4横向)
			ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
			Document doc = new Document(PageSize.A4.rotate());
			doc.setMargins(10, 10, 10, showPageFooter ? 25 : 10);
			PdfWriter pdfWriter = PdfWriter.getInstance(doc, pdfOut);
			if (showPageFooter) {
				pdfWriter.setPageEvent(new PageNumberEvent(baseFont));
			}
			doc.open();
			doc.add(table);
			doc.close();

			return pdfOut.toByteArray();
		} finally {
			ZipSecureFile.setMinInflateRatio(0.01);
		}
	}

	// ==================== Logo 插入 ====================

	private void insertLogo(XSSFSheet sheet, Map<String, Object> params, TemplateLayout layout) {
		Object logoObj = params.get("logo");
		if (logoObj == null || logoObj.toString().isEmpty()) {
			return;
		}
		try {
			byte[] bytes = Base64.getDecoder().decode(logoObj.toString());
			XSSFWorkbook wb = sheet.getWorkbook();
			int type = (bytes.length > 2 && bytes[0] == 'B' && bytes[1] == 'M')
				? Workbook.PICTURE_TYPE_DIB : Workbook.PICTURE_TYPE_PNG;
			int idx = wb.addPicture(bytes, type);

			XSSFDrawing drawing = sheet.createDrawingPatriarch();
			XSSFClientAnchor anchor;
			if (layout.getLogoAnchor() != null) {
				int[] a = layout.getLogoAnchor();
				anchor = new XSSFClientAnchor(0, 0, 0, 0, a[0], a[1], a[2], a[3]);
			} else {
				// 默认放在左上角到数据行之前
				anchor = new XSSFClientAnchor(0, 0, 0, 0, 0, 0,
					Math.min(3, sheet.getRow(0) != null ? sheet.getRow(0).getLastCellNum() : 3),
					Math.min(2, layout.getDataStartRow()));
			}
			anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
			drawing.createPicture(anchor, idx);
		} catch (Exception e) {
			log.warn("Logo图片插入失败: {}", e.getMessage());
		}
	}

	// ==================== 条码/二维码插入 ====================

	private void insertCodeImages(XSSFSheet sheet, int dataSize,
								  List<CodeColumnConfig> codeColumns,
								  TemplateLayout layout) {
		if (codeColumns == null || codeColumns.isEmpty()) {
			return;
		}

		XSSFDrawing drawing = sheet.createDrawingPatriarch();
		XSSFWorkbook wb = sheet.getWorkbook();
		int dataStartRow = layout.getDataStartRow();

		for (CodeColumnConfig cfg : codeColumns) {
			Integer col = layout.getFieldColMap().get(cfg.getFieldName());
			if (col == null) {
				log.warn("条码字段 '{}' 在模板中未找到对应列,跳过", cfg.getFieldName());
				continue;
			}

			for (int r = dataStartRow; r < dataStartRow + dataSize; r++) {
				Row row = sheet.getRow(r);
				if (row == null) continue;

				Cell cell = row.getCell(col);
				String text = getCellText(cell);
				if (text.isEmpty()) continue;

				try {
					BufferedImage img;
					if (cfg.getCodeType() == CodeType.QRCODE) {
						img = CodeUtils.createQRCode(text, cfg.getWidth());
					} else {
						img = BarCodeUtils.getBarCodeImage(text, cfg.getWidth(), cfg.getHeight());
					}
					if (img == null) {
						log.warn("条码生成失败, 行={}, 列={}, 值={}", r, col, text);
						continue;
					}

					ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
					ImageIO.write(img, "PNG", imgOut);
					byte[] imgBytes = imgOut.toByteArray();

					int imgIdx = wb.addPicture(imgBytes, Workbook.PICTURE_TYPE_PNG);
					XSSFClientAnchor anchor = new XSSFClientAnchor(
						0, 0, 0, 0, col, r, col + 1, r + 1);
					anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
					drawing.createPicture(anchor, imgIdx);

					if (cell != null) {
						cell.setCellValue("");
					}
				} catch (IOException e) {
					log.warn("条码图片写入失败, 行={}, 列={}: {}", r, col, e.getMessage());
				}
			}
		}
	}

	// ==================== 页脚合并区域修正(通用版) ====================

	/**
	 * 根据模板扫描到的页脚合并区域偏移量,在填充后重建正确的合并区域。
	 */
	private void ensureFooterMerges(XSSFSheet sheet, int dataSize, TemplateLayout layout) {
		if (layout.getFooterMergeOffsets().isEmpty()) {
			return;
		}

		int footerStart = layout.getDataStartRow() + dataSize;

		// 先移除页脚区域可能错位的合并
		for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
			CellRangeAddress range = sheet.getMergedRegion(i);
			if (range.getFirstRow() >= footerStart) {
				sheet.removeMergedRegion(i);
			}
		}

		// 根据偏移量重建
		for (int[] offset : layout.getFooterMergeOffsets()) {
			int firstRow = footerStart + offset[0];
			int lastRow = footerStart + offset[1];
			int firstCol = offset[2];
			int lastCol = offset[3];
			try {
				sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, firstCol, lastCol));
			} catch (Exception e) {
				log.warn("页脚合并区域重建失败: row={}-{}, col={}-{}: {}",
					firstRow, lastRow, firstCol, lastCol, e.getMessage());
			}
		}
	}

	// ==================== 工具方法 ====================

	private Map<String, byte[]> extractPictures(XSSFSheet sheet) {
		Map<String, byte[]> map = new HashMap<>();
		for (POIXMLDocumentPart part : sheet.getRelations()) {
			if (part instanceof XSSFDrawing drawing) {
				for (XSSFShape shape : drawing.getShapes()) {
					if (shape instanceof XSSFPicture pic) {
						XSSFClientAnchor anchor = (XSSFClientAnchor) pic.getAnchor();
						map.put(anchor.getRow1() + "," + anchor.getCol1(),
							pic.getPictureData().getData());
					}
				}
			}
		}
		return map;
	}

	private float[] getColWidthPercents(Sheet sheet) {
		int maxCols = 0;
		for (int r = sheet.getFirstRowNum(); r <= sheet.getLastRowNum(); r++) {
			Row row = sheet.getRow(r);
			if (row != null && row.getLastCellNum() > maxCols) {
				maxCols = row.getLastCellNum();
			}
		}
		int[] ws = new int[maxCols];
		int sum = 0;
		for (int i = 0; i < maxCols; i++) {
			ws[i] = sheet.getColumnWidth(i);
			sum += ws[i];
		}
		float[] pcts = new float[maxCols];
		for (int i = 0; i < maxCols; i++) {
			pcts[i] = (float) ws[i] / sum * 100;
		}
		return pcts;
	}

	private String getCellText(Cell cell) {
		if (cell == null) return "";
		CellType type = cell.getCellType();
		if (type == CellType.STRING) return cell.getStringCellValue();
		if (type == CellType.NUMERIC) {
			if (DateUtil.isCellDateFormatted(cell)) {
				return new SimpleDateFormat("yyyy-MM-dd").format(cell.getDateCellValue());
			}
			double v = cell.getNumericCellValue();
			return (v == Math.floor(v) && !Double.isInfinite(v))
				? String.valueOf((long) v) : String.valueOf(v);
		}
		if (type == CellType.BOOLEAN) return String.valueOf(cell.getBooleanCellValue());
		if (type == CellType.FORMULA) {
			try {
				return cell.getStringCellValue();
			} catch (Exception e) {
				return String.valueOf(cell.getNumericCellValue());
			}
		}
		return "";
	}

	private Font getCellFont(Cell cell) {
		if (cell instanceof XSSFCell xc) {
			return xc.getCellStyle().getFont();
		}
		return null;
	}

	private boolean hasBorder(Cell cell) {
		short t = cell.getCellStyle().getBorderTop().getCode();
		short b = cell.getCellStyle().getBorderBottom().getCode();
		short l = cell.getCellStyle().getBorderLeft().getCode();
		short r = cell.getCellStyle().getBorderRight().getCode();
		return t + b + l + r > 0;
	}

	private int horAlign(int code) {
		return switch (code) {
			case 2 -> Element.ALIGN_CENTER;
			case 3 -> Element.ALIGN_RIGHT;
			default -> Element.ALIGN_LEFT;   // GENERAL(0) 和 LEFT(1) 均视为左对齐
		};
	}

	private int verAlign(int code) {
		return switch (code) {
			case 2 -> Element.ALIGN_BOTTOM;
			case 3 -> Element.ALIGN_TOP;
			default -> Element.ALIGN_MIDDLE;
		};
	}

	// ==================== 页码事件处理器 ====================

	private static class PageNumberEvent extends PdfPageEventHelper {
		private PdfTemplate totalTemplate;
		private final BaseFont baseFont;
		private int totalPages = 0;

		public PageNumberEvent(BaseFont baseFont) {
			this.baseFont = baseFont;
		}

		@Override
		public void onOpenDocument(PdfWriter writer, Document document) {
			totalTemplate = writer.getDirectContent().createTemplate(50, 16);
		}

		@Override
		public void onEndPage(PdfWriter writer, Document document) {
			totalPages = writer.getPageNumber();
			PdfContentByte cb = writer.getDirectContent();

			float fontSize = 8f;
			String prefix = "第 " + writer.getPageNumber() + " 页,共 ";
			float prefixWidth = baseFont.getWidthPoint(prefix, fontSize);
			float centerX = (document.right() + document.left()) / 2;
			float y = document.bottom() - 15;
			float startX = centerX - prefixWidth / 2;

			cb.beginText();
			cb.setFontAndSize(baseFont, fontSize);
			cb.setTextMatrix(startX, y);
			cb.showText(prefix);
			cb.endText();

			cb.addTemplate(totalTemplate, startX + prefixWidth, y);
		}

		@Override
		public void onCloseDocument(PdfWriter writer, Document document) {
			totalTemplate.beginText();
			totalTemplate.setFontAndSize(baseFont, 8);
			totalTemplate.showText(totalPages + " 页");
			totalTemplate.endText();
		}
	}

	/**
	 * 从 classpath fonts/simsun.ttf 加载中文字体,兼容 JAR 包部署环境。
	 * 若字体文件不存在则抛出异常,让调用方感知失败原因。
	 */
	private BaseFont createChineseBaseFont() throws Exception {
		try (InputStream fontStream = getClass().getClassLoader()
				.getResourceAsStream("fonts/simsun.ttf")) {
			if (fontStream == null) {
				throw new java.io.FileNotFoundException(
					"字体文件未找到: fonts/simsun.ttf,请将字体文件放入 classpath 的 fonts 目录下");
			}
			byte[] fontBytes = fontStream.readAllBytes();
			return BaseFont.createFont("simsun.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED, true, fontBytes, null);
		}
	}
}

模板设计规范

1. 占位符放置

复制代码
A列          B列          C列
─────────────────────────────
{title}                  ← 全局占位符
{date}
─────────────────────────────
姓名         年龄         部门      ← 列标题(自动识别)
{list.name}  {list.age}   {list.dept}  ← 数据行(自动识别)
{list.name}  {list.age}   {list.dept}  ← EasyExcel会自动复制此行
─────────────────────────────
制表人:{creator}         ← 页脚(合并区域自动修正)

2. 边框设置

  • 表头区域:按需设置边框(Logo、标题通常无边框)
  • 列标题行:必须设置完整边框(会与数据行一起强制显示)
  • 数据行模板行:必须设置完整边框(作为样式模板)
  • 页脚区域:按需设置边框

3. 合并区域

  • 表头区域的合并区域会被保留
  • 页脚的合并区域会自动修正行号
  • 数据行内的合并区域不建议使用(EasyExcel可能处理异常)

常见问题排查

Q1: 条码没有显示?

检查清单

  1. 模板中是否有对应的 {list.fieldName} 占位符
  2. CodeColumnConfigfieldName 是否与占位符一致
  3. 数据中该字段是否有值
  4. 查看日志是否有 "条码字段未找到对应列" 警告

Q2: 页脚合并区域错位?

原因:模板中没有定义页脚合并区域,或定义位置错误。

解决:确保页脚合并区域在数据行模板行之后,扫描日志会输出检测到的偏移量:

复制代码
模板布局检测完成: dataStartRow=4, headerRowCount=4, 页脚合并区域=2个

Q3: PDF中文乱码?

原因:字体文件未正确加载。

解决 :确保 classpath:fonts/simsun.ttf 存在,或使用系统字体:

java 复制代码
BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);

Q4: 大数据量内存溢出?

建议

  • 单次导出控制在5000条以内
  • 如需更大数据量,考虑分批次生成多个PDF
  • 调整JVM堆内存:-Xmx2g

技术栈总结

组件 版本 用途
EasyExcel 3.x 模板填充(高性能)
Apache POI 5.x Excel读写、条码图片插入
iText 5 5.5.x PDF构建、表格渲染
Spring Boot 2.7+ 服务封装、响应返回

依赖坐标

xml 复制代码
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.3.2</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>
<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itextpdf</artifactId>
    <version>5.5.13.3</version>
</dependency>

总结

通过模板自动扫描 + 动态布局检测,我们实现了:

零硬编码 :更换模板无需修改Java代码

通用性强 :适用于所有基于EasyExcel模板的报表

条码支持 :一键生成CODE_128/QR码并插入

智能分页 :表头重复、页脚合并自动处理

标签模式:支持每页一个标签的特殊场景

这套方案已在生产环境稳定运行,支撑日均2000+份报表打印,真正做到了"换模板不改代码"。


延伸阅读

源码地址:本文完整代码已开源,欢迎Star⭐️


如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!有任何问题欢迎在评论区交流~

相关推荐
m0_5027249512 小时前
vue3生成pdf
前端·javascript·vue.js·pdf
叶之香12 小时前
一次 Kingston U 盘重定向中获取 Device Descriptor 超时问题排查
c++·windows·visual studio
驯龙高手_追风1 天前
Adobe Acrobat PDF阅读器设置默认滚动翻页
adobe·pdf·adobe acrobat reader·adobe reader
love530love1 天前
MingLi-Bench 项目部署实录:基于 EPGF 架构的工程化实践
人工智能·windows·python·架构·aigc·epgf·mingli-bench
leazer1 天前
Flutter Windows 构建失败:.plugin_symlinks 符号链接异常的排查与修复
windows·flutter
大貔貅喝啤酒1 天前
基于Windows下载安装Android Studio 3.3.2版本教程(2026详细图文版)
android·java·windows·android studio
音视频牛哥1 天前
大牛直播SDK(SmartMediaKit)Windows平台RTSP/RTMP直播播放SDK集成说明(C++版)
windows·音视频·实时音视频·windows rtsp播放器·windows rtmp播放器·超低延迟rtsp播放器·超低延迟rtmp播放器
Irene19911 天前
Windows 11 WSL Ubuntu 环境:实际安装 Hive 踩坑实录
hive·windows·ubuntu
优化控制仿真模型1 天前
【26年社工】初级社会工作者历年真题及答案PDF电子版(2010-2025年)
经验分享·pdf