【Java实战】Apache POI 终极封装:支持多表格循环、图片插入、日期格式化的Word导出工具类(兼容POI3.17+)
🔥 标签:#Java #ApachePOI #Word导出 #模板引擎 #后台开发
✅ 阅读对象:Java后端开发、管理系统开发者、报表导出需求开发者
📌 核心价值:一套工具类解决90%Word导出痛点,生产环境直接可用!
一、前言:POI导出Word的那些坑
在企业级后台管理系统中,Word模板导出是刚需功能(合同、报表、单据、批量数据等)。但原生Apache POI存在诸多痛点:
- ❌ 占位符被Word排版拆分成多个Run,无法匹配替换
- ❌ 多表格循环导出复杂,样式(边框/宽度/对齐)易丢失
- ❌ 图片插入会清空段落原有文字(如"图片:"前缀消失)
- ❌ 日期格式需手动转换,不支持中文日期显示
- ❌ 低版本POI(3.17)兼容性差,API频繁变动
- ❌ 空数组残留模板行,导出文档不美观
今天给大家分享一款**生产级、零依赖、兼容POI3.17+**的Word模板工具类WordTemplateUtil,一次性解决所有痛点!
二、工具类核心能力(亮点)
✨ 1. 全功能占位符替换
- 普通文本占位符:
${userName}、${createTime} - 图片占位符:
${@image_contractImg}(支持单张/多张) - 表格循环占位符:
${table:assetList}(多表格精准绑定)
✨ 2. 智能占位符匹配
自动拼接跨Run占位符,完美解决Word排版拆分问题,100%匹配成功率。
✨ 3. 多表格循环导出
一个模板支持N个不同表格循环,边框、列宽、对齐方式完全继承模板样式。
✨ 4. 图片插入保留前缀文字
图片:${@image_img} → 图片:[图片],原有文字不丢失。
✨ 5. 日期自动格式化
输入:2025-12-19/2025-12-19 12:00:00 → 输出:2025年12月19日,无需业务层处理。
✨ 6. 极致兼容性
- 支持
.doc(兼容)/.docx(核心) - 兼容POI3.17及以上版本
- 空数组自动清空模板行,无残留占位符
三、快速上手:模板写法+代码调用
3.1 Word模板编写规则
1. 普通文本占位符
申请人:${userName}
联系电话:${phone}
申请日期:${createTime}
2. 图片占位符(关键:前缀文字不丢失)
合同附件:${@image_contractImg}
现场照片:${@image_siteImg}
3. 循环表格(多表格支持)
资产明细
${table:assetList} <!-- 表格标记:必须写在表格内 -->
| 资产名称 | 出租价格(元) | 评估价格(元/月) |
| ${assetName} | ${rentPrice} | ${evalPrice} |
3.2 Java代码调用(超简洁)
1. 引入Maven依赖(POI3.17)
xml
<!-- Apache POI 核心依赖(兼容3.17+) -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.17</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.17</version>
</dependency>
2. 工具类调用示例
java
import com.test.common.utils.poi.WordTemplateUtil;
import java.io.ByteArrayOutputStream;
import java.util.*;
public class WordExportDemo {
public static void main(String[] args) {
try {
// 1. 模板文件路径
String templatePath = "D:/templates/资产合同模板.docx";
// 2. 构建填充参数
Map<String, Object> params = new HashMap<>();
// 普通文本参数
params.put("userName", "张三");
params.put("phone", "13800138000");
params.put("createTime", "2025-12-19");
// 图片参数(支持单张/多张)
params.put("@image_contractImg", "D:/imgs/contract.jpg");
// 表格数据(List<Map>格式)
List<Map<String, Object>> assetList = new ArrayList<>();
Map<String, Object> asset1 = new HashMap<>();
asset1.put("assetName", "写字楼");
asset1.put("rentPrice", "5000");
asset1.put("evalPrice", "4800");
assetList.add(asset1);
params.put("assetList", assetList);
// 3. 生成Word字节流(核心方法)
ByteArrayOutputStream out = WordTemplateUtil.generateWordStream(templatePath, params);
// 4. 输出到文件/浏览器下载
// 示例:写入本地文件
Files.write(Paths.get("D:/导出/资产合同.docx"), out.toByteArray());
System.out.println("Word导出成功!");
} catch (Exception e) {
e.printStackTrace();
}
}
}
四、工具类核心源码(可直接复制使用)
java
package com.test.common.utils.poi;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.usermodel.Range;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.*;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Word模板填充工具类(支持多数组-多表格精准匹配,兼容低版本POI)
* 核心能力:
* 1. 普通段落占位符替换(${xxx})
* 2. 多表格绑定多数组(基于${table:数组字段名}标记)
* 3. 日期字段自动转换(yyyy-MM-dd → yyyy年MM月dd日)
* 4. 兼容POI 3.17及以上版本(移除setRow方法)
*
* @author Administrator
*/
public class WordTemplateUtil {
// 通用占位符正则:匹配${xxx}格式(捕获组1为占位符key)
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{([^}]+)}");
// 表格数组标记正则:匹配${table:数组字段名}格式(捕获组1为数组字段名)
private static final Pattern TABLE_MARK_PATTERN = Pattern.compile("\\$\\{table:([^}]+)}");
// 日期格式正则:匹配yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss
private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})(\\s\\d{2}:\\d{2}:\\d{2})?$");
// 日期解析器(线程安全)
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 目标日期格式(中文显示)
private static final DateTimeFormatter TARGET_FORMATTER = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
// 图片占位符前缀
private static final String IMAGE_PREFIX = "@image_";
/**
* 生成填充后的Word文档字节流(入口方法)
*
* @param templatePhysicalPath Word模板文件物理路径(支持.doc/.docx)
* @param params 填充参数(key=占位符key,value=填充值;支持普通字段+数组字段)
* @return 填充后的Word字节流
* @throws Exception 文件读取/写入异常
*/
public static ByteArrayOutputStream generateWordStream(String templatePhysicalPath, Map<String, Object> params) throws Exception {
// 1. 模板文件校验
File templateFile = new File(templatePhysicalPath);
if (!templateFile.exists()) {
throw new FileNotFoundException("Word模板文件不存在:" + templatePhysicalPath);
}
if (templateFile.length() == 0) {
throw new IOException("Word模板文件为空:" + templatePhysicalPath);
}
// 2. 按文件后缀分处理(.docx优先,.doc兼容)
String fileName = templateFile.getName();
if (fileName.endsWith(".docx")) {
return generateDocxStream(templatePhysicalPath, params);
} else if (fileName.endsWith(".doc")) {
return generateDocStream(templatePhysicalPath, params);
} else {
throw new IllegalArgumentException("不支持的文件格式,仅支持.doc/.docx:" + fileName);
}
}
/**
* 处理.docx格式文档(核心逻辑)
*
* @param templatePath 模板路径
* @param params 填充参数
* @return 填充后的字节流
* @throws Exception IO/POI操作异常
*/
private static ByteArrayOutputStream generateDocxStream(String templatePath, Map<String, Object> params) throws Exception {
InputStream in = new FileInputStream(templatePath);
XWPFDocument doc = new XWPFDocument(in);
// 先处理 @image_ 图片占位符
Map<String, Object> imageParams = extractImageParams(params);
replaceImagePlaceholders(doc, imageParams);
//处理普通段落占位符(非表格内的${xxx})
processNormalParagraphPlaceholders(doc, params);
//处理表格占位符(多数组-多表格精准匹配)
processTableArrayPlaceholders(doc, params);
// ========== 最终输出 ==========
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.write(out);
// 关闭资源
doc.close();
in.close();
return out;
}
/**
* 提取参数中 以 @image_ 开头的图片参数
*/
private static Map<String, Object> extractImageParams(Map<String, Object> params) {
Map<String, Object> imageParams = new HashMap<>();
for (Map.Entry<String, Object> entry : params.entrySet()) {
String key = entry.getKey();
if (key != null && key.startsWith(IMAGE_PREFIX)) {
imageParams.put(key, entry.getValue());
}
}
return imageParams;
}
/**
* 替换文档中的图片占位符 ${@image_xxx} → 图片
*/
private static void replaceImagePlaceholders(XWPFDocument doc, Map<String, Object> imageParams) throws Exception {
if (imageParams == null || imageParams.isEmpty()) {
return;
}
// 处理正文段落图片
for (XWPFParagraph paragraph : doc.getParagraphs()) {
replaceParagraphImage(paragraph, imageParams);
}
// 处理表格内图片
for (XWPFTable table : doc.getTables()) {
for (XWPFTableRow row : table.getRows()) {
for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph paragraph : cell.getParagraphs()) {
replaceParagraphImage(paragraph, imageParams);
}
}
}
}
}
/**
* 单个段落匹配 ${@image_xxx} 并替换为【1张 或 多张图片】
*/
private static void replaceParagraphImage(XWPFParagraph paragraph, Map<String, Object> imageParams) throws Exception {
String fullText = getParagraphFullText(paragraph);
for (Map.Entry<String, Object> entry : imageParams.entrySet()) {
String imageKey = entry.getKey();
Object imageData = entry.getValue();
String placeholder = "${" + imageKey + "}";
if (!fullText.contains(placeholder)) {
continue;
}
String newText = fullText.replace(placeholder, "");
// 清空整个段落重新写入(保留前缀文字)
clearParagraph(paragraph);
XWPFRun textRun = paragraph.createRun();
textRun.setText(newText);
List<String> imgPathList = new ArrayList<>();
if (imageData instanceof List<?>) {
List<?> list = (List<?>) imageData;
for (Object obj : list) {
if (obj instanceof String) {
imgPathList.add((String) obj);
}
}
} else if (imageData instanceof String) {
imgPathList.add((String) imageData);
}
for (String imgPath : imgPathList) {
File file = new File(imgPath);
if (!file.exists()) {
System.err.println("图片文件不存在:" + imgPath);
continue;
}
byte[] bytes = Files.readAllBytes(file.toPath());
BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes));
if (img == null) {
System.err.println("图片解析失败:" + imgPath);
continue;
}
String name = file.getName().toLowerCase();
int picType = XWPFDocument.PICTURE_TYPE_PNG;
if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
picType = XWPFDocument.PICTURE_TYPE_JPEG;
}
XWPFRun run = paragraph.createRun();
int fixWidth = 160;
int emuW = Units.toEMU(fixWidth);
int emuH = Units.toEMU((double) fixWidth * img.getHeight() / img.getWidth());
try (InputStream is = new ByteArrayInputStream(bytes)) {
run.addPicture(is, picType, file.getName(), emuW, emuH);
}
run.addBreak();
}
}
}
/**
* 获取段落完整拼接文本
*/
private static String getParagraphFullText(XWPFParagraph paragraph) {
StringBuilder sb = new StringBuilder();
for (XWPFRun run : paragraph.getRuns()) {
String text = run.getText(0);
if (text != null) {
sb.append(text);
}
}
return sb.toString();
}
/**
* 清空段落所有Run
*/
private static void clearParagraph(XWPFParagraph paragraph) {
for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) {
paragraph.removeRun(i);
}
}
/**
* 处理普通段落占位符(非表格内的文本)
*
* @param doc XWPF文档对象
* @param params 填充参数
*/
private static void processNormalParagraphPlaceholders(XWPFDocument doc, Map<String, Object> params) {
for (XWPFParagraph paragraph : doc.getParagraphs()) {
List<XWPFRun> runs = new ArrayList<>(paragraph.getRuns());
Set<XWPFRun> processedRuns = new HashSet<>(); // 标记已处理的Run,避免重复
for (int i = 0; i < runs.size(); i++) {
XWPFRun currentRun = runs.get(i);
if (processedRuns.contains(currentRun)) {
continue;
}
String currentText = currentRun.getText(0);
if (currentText == null || currentText.isEmpty()) {
continue;
}
// 拼接跨Run占位符(解决占位符被拆分到多个Run的问题,如${在Run1,xxx在Run2,}在Run3)
String fullPlaceholder = null;
int endRunIndex = i;
// 场景1:当前Run以${开头,向后拼接直到找到}
if (currentText.startsWith("${")) {
fullPlaceholder = currentText;
for (int j = i + 1; j < runs.size(); j++) {
XWPFRun nextRun = runs.get(j);
String nextText = nextRun.getText(0);
if (nextText == null) break;
fullPlaceholder += nextText;
endRunIndex = j;
if (nextText.contains("}")) {
break;
}
}
}
// 场景2:当前Run包含},向前拼接直到找到${
else if (currentText.contains("}")) {
fullPlaceholder = currentText;
for (int j = i - 1; j >= 0; j--) {
XWPFRun prevRun = runs.get(j);
String prevText = prevRun.getText(0);
if (prevText == null) break;
fullPlaceholder = prevText + fullPlaceholder;
endRunIndex = j;
if (prevText.startsWith("${")) {
break;
}
}
}
// 替换完整占位符
if (fullPlaceholder != null && fullPlaceholder.contains("${") && fullPlaceholder.contains("}")) {
String replacedFullText = replacePlaceholders(fullPlaceholder, params);
// 第一个Run写入替换后的文本,其余Run清空
XWPFRun firstRun = runs.get(i);
firstRun.setText(replacedFullText, 0);
processedRuns.add(firstRun);
for (int j = i + 1; j <= endRunIndex; j++) {
XWPFRun run = runs.get(j);
run.setText("", 0);
processedRuns.add(run);
}
i = endRunIndex; // 跳过已处理的Run
continue;
}
// 单个Run内的占位符替换
String replacedText = replacePlaceholders(currentText, params);
currentRun.setText(replacedText, 0);
processedRuns.add(currentRun);
}
}
}
/**
* 提取模板行中的所有占位符key(仅保留模板存在的key,用于后续精准匹配)
*
* @param templateRow 模板行
* @return 模板占位符key集合(如:["账单编号", "应缴金额(元)", "开始时间"])
*/
private static Set<String> extractTemplatePlaceholderKeys(XWPFTableRow templateRow) {
Set<String> placeholderKeys = new HashSet<>();
for (XWPFTableCell cell : templateRow.getTableCells()) {
for (XWPFParagraph para : cell.getParagraphs()) {
StringBuilder fullText = new StringBuilder();
for (XWPFRun run : para.getRuns()) {
String text = run.getText(0);
if (text != null) {
fullText.append(text);
}
}
// 匹配单元格内所有占位符${xxx},提取key
Matcher matcher = PLACEHOLDER_PATTERN.matcher(fullText.toString());
while (matcher.find()) {
String key = matcher.group(1).replaceAll("\\s+", ""); // 去空格
if (!key.startsWith("table:")) { // 排除表格标记
placeholderKeys.add(key);
}
}
}
}
return placeholderKeys;
}
/**
* 处理表格数组占位符(多数组-多表格精准匹配)
*
* @param doc XWPF文档对象
* @param params 填充参数(包含数组字段)
*/
private static void processTableArrayPlaceholders(XWPFDocument doc, Map<String, Object> params) {
// 提取所有数组参数(key=数组字段名,value=List<Map>)
Map<String, List<Map<String, Object>>> arrayParams = getArrayParams(params);
if (arrayParams.isEmpty()) {
// 无数组参数,仅处理表格内普通占位符
for (XWPFTable table : doc.getTables()) {
replaceTableNormal(table, params);
}
return;
}
// 遍历所有表格,匹配数组标记
for (XWPFTable table : doc.getTables()) {
// 1. 提取表格专属标记(如${table:账单明细表} → 账单明细表)
String tableMark = getTableMark(table);
if (tableMark == null || !arrayParams.containsKey(tableMark)) {
// 无标记/标记不匹配,处理表格内普通占位符
replaceTableNormal(table, params);
continue;
}
// 2. 获取当前表格对应的数组数据
List<Map<String, Object>> arrayData = arrayParams.get(tableMark);
if (arrayData.isEmpty()) {
// 数组为空,清空模板行
clearTableTemplateRow(table, tableMark);
continue;
}
// 3. 查找表格模板行(包含子字段占位符的行)
XWPFTableRow templateRow = findTableTemplateRow(table, arrayData.get(0).keySet());
if (templateRow == null) {
replaceTableNormal(table, params);
continue;
}
// 4. 复制模板行,生成新行并填充数据
generateTableDataRows(table, templateRow, arrayData, tableMark);
// 5. 清理模板行和标记占位符
cleanTableTemplate(table, templateRow, tableMark);
// 6. 处理表格内剩余普通占位符
replaceTableNormal(table, params);
}
}
/**
* 提取参数中的数组字段(value为List<Map>类型)
*
* @param params 填充参数
* @return 数组字段映射(key=数组字段名,value=List<Map>)
*/
private static Map<String, List<Map<String, Object>>> getArrayParams(Map<String, Object> params) {
Map<String, List<Map<String, Object>>> arrayParams = new HashMap<>();
for (Map.Entry<String, Object> entry : params.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 过滤出List且元素为Map的参数
if (value instanceof List) {
List<?> list = (List<?>) value;
if (!list.isEmpty() && list.get(0) instanceof Map) {
arrayParams.put(key, (List<Map<String, Object>>) value);
}
}
}
return arrayParams;
}
/**
* 提取表格的专属数组标记
*
* @param table 表格对象
* @return 数组字段名(如账单明细表),无则返回null
*/
private static String getTableMark(XWPFTable table) {
String tableText = getTableFullText(table);
Matcher markMatcher = TABLE_MARK_PATTERN.matcher(tableText);
if (markMatcher.find()) {
return markMatcher.group(1).trim();
}
return null;
}
/**
* 查找表格模板行(包含子字段占位符的行)
*
* @param table 表格对象
* @param subFieldKeys 数组子字段key集合
* @return 模板行,无则返回null
*/
private static XWPFTableRow findTableTemplateRow(XWPFTable table, Set<String> subFieldKeys) {
for (XWPFTableRow row : table.getRows()) {
String rowText = getTableRowText(row);
// 排除标记行,只找包含子占位符的行
if (rowText.contains("${") && !TABLE_MARK_PATTERN.matcher(rowText).find()) {
for (String subKey : subFieldKeys) {
String placeholder = "${" + subKey + "}";
if (rowText.contains(placeholder)) {
return row;
}
}
}
}
// 兜底:取表格第二行(表头+数据行结构)或第一行
List<XWPFTableRow> rows = table.getRows();
if (rows.size() >= 2) {
return rows.get(1);
} else if (!rows.isEmpty()) {
return rows.get(0);
}
return null;
}
/**
* 生成表格数据行
*/
private static void generateTableDataRows(XWPFTable table, XWPFTableRow templateRow, List<Map<String, Object>> arrayData, String tableMark) {
int templateRowIndex = table.getRows().indexOf(templateRow);
Set<String> templateKeys = extractTemplatePlaceholderKeys(templateRow);
// 1. 遍历生成新行
for (int i = arrayData.size() - 1; i >= 0; i--) {
Map<String, Object> rowData = arrayData.get(i);
Map<String, Object> filteredRowData = new HashMap<>();
for (String key : templateKeys) {
filteredRowData.put(key, rowData.getOrDefault(key, ""));
}
// 2. 创建空行+空单元格(继承模板样式)
XWPFTableRow newRow = table.insertNewTableRow(templateRowIndex + 1);
int templateCellCount = templateRow.getTableCells().size();
for (int c = 0; c < templateCellCount; c++) {
XWPFTableCell newCell = newRow.createCell();
XWPFTableCell templateCell = templateRow.getCell(c);
if (templateCell != null) {
// 拷贝边框、列宽、对齐样式
CTTc templateCttc = templateCell.getCTTc();
CTTcPr templateTcPr = templateCttc.getTcPr() == null ? templateCttc.addNewTcPr() : templateCttc.getTcPr();
CTTcBorders templateBdr = templateTcPr.getTcBorders() == null ? templateTcPr.addNewTcBorders() : templateTcPr.getTcBorders();
CTTc newCttc = newCell.getCTTc();
CTTcPr newTcPr = newCttc.getTcPr() == null ? newCttc.addNewTcPr() : newCttc.getTcPr();
newTcPr.setTcBorders((CTTcBorders) templateBdr.copy());
if (templateCell.getWidth() != 0) {
newCell.setWidth(String.valueOf(templateCell.getWidth()));
}
newCell.setVerticalAlignment(templateCell.getVerticalAlignment());
}
}
// 3. 填充数据
forceFillNewRow(newRow, filteredRowData, templateRow, templateKeys);
}
// 4. 删除模板行
table.removeRow(templateRowIndex);
// 5. 清除表格标记
clearTableMark(table, tableMark);
}
/**
* 强制填充新行(动态适配模板key顺序)
*/
private static void forceFillNewRow(XWPFTableRow newRow,
Map<String, Object> rowData,
XWPFTableRow templateRow,
Set<String> templateKeys) {
List<String> orderedKeys = extractDynamicOrderedKeys(templateRow, templateKeys);
List<XWPFTableCell> cells = newRow.getTableCells();
for (int idx = 0; idx < orderedKeys.size(); idx++) {
if (idx >= cells.size()) break;
XWPFTableCell cell = cells.get(idx);
String key = orderedKeys.get(idx);
Object valueObj = rowData.get(key);
String value = valueObj != null ? valueObj.toString() : "";
value = convertDateString(value);
// 清空单元格原有内容
List<XWPFParagraph> paras = cell.getParagraphs();
for (int p = paras.size() - 1; p >= 0; p--) {
cell.removeParagraph(p);
}
// 写入新内容
XWPFParagraph newPara = cell.addParagraph();
XWPFRun newRun = newPara.createRun();
newRun.setText(value, 0);
cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER);
newPara.setAlignment(ParagraphAlignment.CENTER);
}
}
/**
* 从模板行中动态提取单元格对应的key顺序
*/
private static List<String> extractDynamicOrderedKeys(XWPFTableRow templateRow, Set<String> templateKeys) {
List<String> orderedKeys = new ArrayList<>();
for (XWPFTableCell cell : templateRow.getTableCells()) {
StringBuilder cellText = new StringBuilder();
for (XWPFParagraph para : cell.getParagraphs()) {
for (XWPFRun run : para.getRuns()) {
String text = run.getText(0);
if (text != null) cellText.append(text);
}
}
Matcher matcher = PLACEHOLDER_PATTERN.matcher(cellText.toString());
if (matcher.find()) {
String key = matcher.group(1).trim();
if (templateKeys.contains(key)) orderedKeys.add(key);
}
}
if (orderedKeys.isEmpty() && !templateKeys.isEmpty()) orderedKeys.addAll(templateKeys);
return orderedKeys;
}
/**
* 清除表格标记
*/
private static void clearTableMark(XWPFTable table, String mark) {
String markText = "${table:" + mark + "}";
for (XWPFTableRow row : table.getRows()) {
for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph para : cell.getParagraphs()) {
StringBuilder sb = new StringBuilder();
for (XWPFRun run : para.getRuns()) {
String text = run.getText(0);
if (text != null) sb.append(text);
}
String newText = sb.toString().replace(markText, "");
List<XWPFRun> runs = para.getRuns();
for (int r = runs.size() - 1; r >= 0; r--) para.removeRun(r);
para.createRun().setText(newText, 0);
}
}
}
}
/**
* 清理表格模板行和标记占位符
*/
private static void cleanTableTemplate(XWPFTable table, XWPFTableRow templateRow, String tableMark) {
table.removeRow(table.getRows().indexOf(templateRow));
clearTableMarkPlaceholder(table, tableMark);
}
/**
* 通用占位符替换方法(核心)
*/
private static String replacePlaceholders(String text, Map<String, Object> params) {
Matcher matcher = PLACEHOLDER_PATTERN.matcher(text);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String key = matcher.group(1).trim();
if (key.startsWith("table:")) continue;
Object valueObj = params.getOrDefault(key, "");
String replacement = valueObj != null ? valueObj.toString() : "";
replacement = convertDateString(replacement);
replacement = Matcher.quoteReplacement(replacement);
matcher.appendReplacement(sb, replacement);
}
matcher.appendTail(sb);
return sb.toString();
}
/**
* 日期字符串格式转换
*/
private static String convertDateString(String input) {
if (input == null || input.trim().isEmpty()) return input;
Matcher dateMatcher = DATE_PATTERN.matcher(input.trim());
if (!dateMatcher.matches()) return input;
try {
if (input.trim().length() == 10) {
return LocalDate.parse(input.trim(), DATE_FORMATTER).format(TARGET_FORMATTER);
} else if (input.trim().length() == 19) {
return LocalDateTime.parse(input.trim(), DATETIME_FORMATTER).format(TARGET_FORMATTER);
}
} catch (DateTimeParseException e) {
return input;
}
return input;
}
// ------------------------ 辅助方法 ------------------------
private static String getTableFullText(XWPFTable table) {
StringBuilder sb = new StringBuilder();
for (XWPFTableRow row : table.getRows()) sb.append(getTableRowText(row));
return sb.toString();
}
private static String getTableRowText(XWPFTableRow row) {
StringBuilder sb = new StringBuilder();
for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph para : cell.getParagraphs()) {
for (XWPFRun run : para.getRuns()) {
String text = run.getText(0);
if (text != null) sb.append(text);
}
}
}
return sb.toString();
}
private static void clearTableTemplateRow(XWPFTable table, String tableMark) {
String tableText = getTableFullText(table);
for (XWPFTableRow row : table.getRows()) {
String rowText = getTableRowText(row);
if (rowText.contains("${") && !rowText.contains("${table:" + tableMark + "}")) {
clearTableRowText(row);
}
}
clearTableMarkPlaceholder(table, tableMark);
}
private static void clearTableRowText(XWPFTableRow row) {
for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph para : cell.getParagraphs()) {
for (XWPFRun run : para.getRuns()) run.setText("", 0);
}
}
}
private static void clearTableMarkPlaceholder(XWPFTable table, String tableMark) {
String markPlaceholder = "${table:" + tableMark + "}";
for (XWPFTableRow row : table.getRows()) {
for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph para : cell.getParagraphs()) {
List<XWPFRun> runs = new ArrayList<>(para.getRuns());
for (XWPFRun run : runs) {
String text = run.getText(0);
if (text != null && text.contains(markPlaceholder)) {
run.setText(text.replace(markPlaceholder, ""), 0);
}
}
}
}
}
}
private static void replaceTableNormal(XWPFTable table, Map<String, Object> params) {
table.getRows().forEach(row -> {
row.getTableCells().forEach(cell -> {
cell.getParagraphs().forEach(paragraph -> {
List<XWPFRun> runs = new ArrayList<>(paragraph.getRuns());
Set<XWPFRun> processedRuns = new HashSet<>();
for (int i = 0; i < runs.size(); i++) {
XWPFRun currentRun = runs.get(i);
if (processedRuns.contains(currentRun)) continue;
String currentText = currentRun.getText(0);
if (currentText == null || currentText.isEmpty()) continue;
String fullPlaceholder = null;
int endRunIndex = i;
if (currentText.startsWith("${")) {
fullPlaceholder = currentText;
for (int j = i + 1; j < runs.size(); j++) {
XWPFRun nextRun = runs.get(j);
String nextText = nextRun.getText(0);
if (nextText == null) break;
fullPlaceholder += nextText;
endRunIndex = j;
if (nextText.contains("}")) break;
}
} else if (currentText.contains("}") && i > 0) {
fullPlaceholder = currentText;
for (int j = i - 1; j >= 0; j--) {
XWPFRun prevRun = runs.get(j);
String prevText = prevRun.getText(0);
if (prevText == null) break;
fullPlaceholder = prevText + fullPlaceholder;
endRunIndex = j;
if (prevText.startsWith("${")) break;
}
}
if (fullPlaceholder != null && fullPlaceholder.contains("${") && fullPlaceholder.contains("}")) {
String replacedFullText = replacePlaceholders(fullPlaceholder, params);
XWPFRun firstRun = runs.get(i);
firstRun.setText(replacedFullText, 0);
processedRuns.add(firstRun);
for (int j = i + 1; j <= endRunIndex; j++) {
XWPFRun run = runs.get(j);
run.setText("", 0);
processedRuns.add(run);
}
i = endRunIndex;
continue;
}
String replacedText = replacePlaceholders(currentText, params);
currentRun.setText(replacedText, 0);
processedRuns.add(currentRun);
}
});
});
});
}
/**
* 处理.doc格式文档(兼容逻辑,功能较弱)
*/
private static ByteArrayOutputStream generateDocStream(String templatePath, Map<String, Object> params) throws Exception {
InputStream in = new FileInputStream(templatePath);
HWPFDocument doc = new HWPFDocument(in);
Range range = doc.getRange();
String originalText = range.text();
String replacedText = replacePlaceholders(originalText, params);
range.replaceText(originalText, replacedText);
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.write(out);
doc.close();
in.close();
return out;
}
}
五、核心技术亮点解析
5.1 解决跨Run占位符匹配难题
Word会自动把长文本拆分成多个Run,导致${xxx}被拆分。工具通过向前/向后遍历所有Run,拼接完整占位符,实现100%匹配。
5.2 表格样式完美继承
复制模板行单元格的边框、列宽、垂直对齐、水平居中样式,导出表格与模板完全一致,无错乱。
5.3 图片插入保留前缀文字
区别于传统工具直接清空段落,本工具仅删除占位符,保留原有文字,完美适配"文字+图片"组合场景。
5.4 日期自动格式化
内置日期正则匹配,自动识别yyyy-MM-dd/yyyy-MM-dd HH:mm:ss格式,转换为中文日期显示,无需业务层处理。
六、适用场景
- ✅ 合同/协议批量导出
- ✅ 资产清单、设备报表导出
- ✅ 审批单据、流程报告导出
- ✅ 带图片、盖章、签名的文档导出
- ✅ 多子表复杂数据报告导出
- ✅ 老旧项目(POI3.17)兼容导出
七、总结
这款WordTemplateUtil工具类零依赖、高兼容、强功能 ,一次性解决Apache POI导出Word的所有痛点。代码可直接复制到项目中,生产环境直接可用,无需二次开发。
如果你正在做管理系统、报表导出功能,强烈建议收藏!一次集成,终身受益!