【Java】Apache POI 终极封装:支持多表格循环、图片插入、日期格式化的Word导出工具类(兼容POI3.17+)

【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的所有痛点。代码可直接复制到项目中,生产环境直接可用,无需二次开发。

如果你正在做管理系统、报表导出功能,强烈建议收藏!一次集成,终身受益!

相关推荐
SimonKing10 小时前
IP定位库的完美替代品:ip2region,开源、免费!
java·后端·程序员
XiYang-DING10 小时前
【Spring】Lombok
java·后端·spring
凤山老林10 小时前
AI辅助编程:Copilot在Java开发中的最佳实践
java·人工智能·copilot
铁打的阿秀10 小时前
IDEA启动项目报错: 加载主类 com.seeburger.webedi.system.SystemApplication 时出现 LinkageError
java·ide·intellij-idea
Yeats_Liao10 小时前
物联网接入层技术剖析(一):从select到epoll
java·linux·后端·物联网·struts
上弦月-编程10 小时前
Java类与对象:编程核心解密
java·开发语言·jvm
Kapaseker10 小时前
为什么 Java 的数组需要 new 出来
android·java·kotlin
Dicky-_-zhang10 小时前
线上故障排查与应急响应实战:从零开始建立你的SRE体系
java·jvm
大大杰哥10 小时前
从 Volatile 到 ThreadLocal:Java 线程安全机制备忘
java·开发语言·jvm