Excel模板智能转PDF:零硬编码的通用打印解决方案
从200行硬编码到1200行通用框架,一次重构彻底解决企业报表打印难题
背景痛点
在企业级应用中,Excel报表转PDF打印是一个高频需求。传统实现往往存在以下问题:
- 硬编码严重:每个报表都需要单独写一套转换逻辑,数据行位置、表头行数、边框样式全部写死
- 维护成本高:更换模板格式需要修改大量Java代码
- 条码支持弱:条形码/二维码生成与插入逻辑分散,难以复用
- 分页处理复杂:表头重复、页脚合并区域错位等问题频发
本文介绍的 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 // 高度毫米
);
实现原理:
- 逐条数据调用
buildSingleLabelPdf()生成单页PDF字节数组 - 使用
PdfCopy合并所有单页到一个文档 - 每页独立设置页面尺寸为标签纸大小
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<CodeColumnConfig> 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<字段名, int[]{row, col}>
*/
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: 条码没有显示?
检查清单:
- 模板中是否有对应的
{list.fieldName}占位符 CodeColumnConfig的fieldName是否与占位符一致- 数据中该字段是否有值
- 查看日志是否有 "条码字段未找到对应列" 警告
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⭐️
如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!有任何问题欢迎在评论区交流~