文章

SpringBoot_Word模板导出功能_多行复制功能的实现

Word模板导出框架:deepoove公司的poi-tl框架使用.以及自实现的拓展功能:Word中的表格需要进行多行的数据复制时的实现类;

SpringBoot_Word模板导出功能_多行复制功能的实现

一.基础使用

0.简介

详细介绍见官方文档,poi-tl(poi template language)是Word模板引擎,使用模板和数据创建很棒的Word文档。

现有Word模板导出方案对比:

方案移植性功能性易用性
Poi-tlJava跨平台Word模板引擎,基于Apache POI,提供更友好的API低代码,准备文档模板和数据即可
Apache POIJava跨平台Apache项目,封装了常见的文档操作,也可以操作底层XML结构文档不全,这里有一个教程:Apache POI Word快速入门
FreemarkerXML跨平台仅支持文本,很大的局限性不推荐,XML结构的代码几乎无法维护
OpenOffice部署OpenOffice,移植性较差-需要了解OpenOffice的API
HTML浏览器导出依赖浏览器的实现,移植性较差HTML不能很好的兼容Word的格式,样式糟糕-
Jacob、winlibWindows平台-复杂,完全不推荐使用

poi-tl具体的功能

Word模板引擎功能描述
文本将标签渲染为文本
图片将标签渲染为图片
表格将标签渲染为表格
列表将标签渲染为列表
图表条形图(3D条形图)、柱形图(3D柱形图)、面积图(3D面积图)、折线图(3D折线图)、雷达图、饼图(3D饼图)、散点图等图表渲染
If Condition判断根据条件隐藏或者显示某些文档内容(包括文本、段落、图片、表格、列表、图表等)
Foreach Loop循环根据集合循环某些文档内容(包括文本、段落、图片、表格、列表、图表等)
Loop表格行循环复制渲染表格的某一行
Loop表格列循环复制渲染表格的某一列
Loop有序列表支持有序列表的循环,同时支持多级列表
Highlight代码高亮word中代码块高亮展示,支持26种语言和上百种着色样式
Markdown将Markdown渲染为word文档
Word批注完整的批注功能,创建批注、修改批注等
Word附件Word中插入附件
SDT内容控件内容控件内标签支持
Textbox文本框文本框内标签支持
图片替换将原有图片替换成另一张图片
书签、锚点、超链接支持设置书签,文档内锚点和超链接功能
Expression Language完全支持SpringEL表达式,可以扩展更多的表达式:OGNL, MVEL…​
样式模板即样式,同时代码也可以设置样式
模板嵌套模板包含子模板,子模板再包含子模板
合并Word合并Merge,也可以在指定位置进行合并
用户自定义函数(插件)插件化设计,在文档任何位置执行函数

1.框架依赖引入

1
2
3
4
5
<dependency>
  <groupId>com.deepoove</groupId>
  <artifactId>poi-tl</artifactId>
  <version>1.12.2</version>
</dependency>

2.基础使用

2.2.1 创建word文件模板,加入数据标记

模板文件

文件内容示例

2.2.2 代码数据填充

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    @SneakyThrows
    @PostMapping("export")
    public void export() {
        InputStream templateIns = ResourceUtil.getStream("templates/word导出.docx");//模板文件放置在项目中的resources目录下
        Map<String, Object> exportMap = new HashMap<>();
        /* ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  导出数据填充 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ */
        exportMap.put("title", "测试标题");
        exportMap.put("content", "测试内容");
        //图片填充
        exportMap.put("img1", Pictures.ofUrl("http://www.baidu.com/img/bdlogo.png").size(20, 20).create());
        exportMap.put("img2", Pictures.ofUrl("http://rongcloud-web.qiniudn.com/docs_demo_rongcloud_logo.png").size(20, 20).create());
        //循环行数据填充
        exportMap.put("students",new ArrayList<Map<String, Object>>());
            add(new HashMap<String, Object>());
        }});
        //区块对数据填充
        exportMap.put("person",new ArrayList<Map<String, Object>>());
            add(new HashMap<String, Object>());
        }});
        /* ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  导出数据填充 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ */
        //创建行循环策略
        LoopRowTableRenderPolicy rowTableRenderPolicy = new LoopRowTableRenderPolicy();
        Configure configure = Configure.builder()
                .bind("students", rowTableRenderPolicy) //循环行数据绑定
                //区块对是默认插件,不需要主动申明
                .build();
        XWPFTemplate template = XWPFTemplate.compile(templateIns, configure)
                .render(exportMap);
        String fileName = DateUtil.format(new Date(), "yyyyMMddHHmmss") + "-导出.docx";
        setFileName(response, fileName);
        //写回到响应流中
        OutputStream out = response.getOutputStream();
        BufferedOutputStream bos = new BufferedOutputStream(out);
        template.write(bos);
        bos.flush();
        out.flush();
        PoitlIOUtils.closeQuietlyMulti(template, bos, out);
    }

    //设置下载文件名
    @SneakyThrows
    private void setFileName(HttpServletResponse response, String fileName) {
        StringBuilder contentDispositionValue = new StringBuilder();
        contentDispositionValue.append("attachment; filename=").append(URLUtil.encode(fileName, "UTF-8")).append(";").append("filename*=").append("utf-8''").append(URLUtil.encode(fileName, "UTF-8"));
        response.addHeader("Access-Control-Allow-Origin", "*");
        response.addHeader("Access-Control-Expose-Headers", "Content-Disposition,download-filename");
        response.setHeader("Content-disposition", contentDispositionValue.toString());
        response.setHeader("download-filename", URLUtil.encode(fileName, "UTF-8"));
        response.setContentType("application/octet-stream");
    }

3.填充结果

填充结果

更多的填充插件功能,参考官网的示例.

二.拓展Word中表格多行复制

官方的拓展插件中虽然可以使用区块对,进行多行的循环,但是区块对针对表格中的部分内容(例如某两行)进行循环,则不支持. 所以此块需要使用自实现的多行表格的循环处理; 表格多行循环模板定义

模板内容

模板内容

插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
import cn.hutool.core.util.StrUtil;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.data.PictureRenderData;
import com.deepoove.poi.exception.RenderException;
import com.deepoove.poi.policy.PictureRenderPolicy;
import com.deepoove.poi.policy.RenderPolicy;
import com.deepoove.poi.template.ElementTemplate;
import com.deepoove.poi.template.run.RunTemplate;
import com.deepoove.poi.util.TableTools;
import lombok.SneakyThrows;
import org.apache.poi.xwpf.usermodel.*;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRow;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/*循环复制多行的策略*/
public class LoopMultiRowTableRenderPolicy implements RenderPolicy {
    private String itemPrefix;
    private String itemSuffix;

    private int startRowStart;//起始行
    private int startRowEnd;//结束行
    private int rowDiff;//行数差异


    public LoopMultiRowTableRenderPolicy() {
        this("[", "]");
    }

    public LoopMultiRowTableRenderPolicy(String itemPrefix, String itemSuffix) {
        this.itemPrefix = itemPrefix;
        this.itemSuffix = itemSuffix;
    }

    /**
     * @description 深入拷贝指定行, 返回新的行数据
     * @create: 2025/3/13 11:05
     **/
    @SneakyThrows
    private XWPFTableRow deepCloneRow(XWPFTable table, XWPFTableRow row) {
        CTRow ctrow = CTRow.Factory.parse(row.getCtRow().newInputStream());//重点行
        XWPFTableRow newRow = new XWPFTableRow(ctrow, table);
        return newRow;
    }

    /**
     * @description 根据行的模板标记, 填充数据
     * @create: 2025/4/10 17:13
     **/
    @SneakyThrows
    private void fillRowData(XWPFTableRow row, Map<String, Object> rowData) {
        List<XWPFTableCell> tableCells = row.getTableCells();// 在新增的行上面创建cell
        for (XWPFTableCell cell : tableCells) {
            for (XWPFParagraph paragraph : cell.getParagraphs()) {
                List<XWPFRun> runs = paragraph.getRuns();
                for (int i = 0; i < runs.size(); i++) {
                    XWPFRun r = runs.get(i);
                    String text = r.getText(0);
                    if (StrUtil.isNotEmpty(text)) {
                        String pureText = text.replace(itemPrefix, "")
                                .replace(itemSuffix, "")
                                .replace("@", "");
                        if (rowData.containsKey(pureText)) {
                            Object rowDataItem = rowData.get(pureText);
                            if (rowDataItem instanceof Number || rowDataItem instanceof String) {
                                r.setText(String.valueOf(rowDataItem), 0);//要深入到原cell中的run替换内容才能保证样式一致
                            }
                            if (rowDataItem instanceof PictureRenderData) {
                                XWPFRun run = paragraph.createRun();
                                PictureRenderData picture = (PictureRenderData) rowDataItem;
                                PictureRenderPolicy.Helper.renderPicture(run, picture);
                                paragraph.removeRun(0);//移除可能已经存在的图片
                            }
                        }
                    }
                }
            }
        }
    }

    @Override
    public void render(ElementTemplate eleTemplate, Object data, XWPFTemplate template) {
        RunTemplate runTemplate = (RunTemplate) eleTemplate;
        XWPFRun run = runTemplate.getRun();
        try {
            if (!TableTools.isInsideTable(run)) {
                throw new IllegalStateException(
                        "The template tag " + runTemplate.getSource() + " must be inside a table");
            }
            XWPFTableCell startTagCell = (XWPFTableCell) ((XWPFParagraph) run.getParent()).getBody();
            XWPFTable table = startTagCell.getTableRow().getTable();
            run.setText("", 0);

            startRowStart = getTemplateRowIndex(startTagCell);//开始行的索引
            // 找到结束行的索引
            int templateRowIndexStartEnd = getTemplateRowIndexByTemplateTag(table, startRowStart, "↑" + runTemplate.getTagName(), true);
            if (templateRowIndexStartEnd == -1) {
                throw new IllegalStateException(
                        "The template end tag " + runTemplate.getSource() + " not found in table");
            }

            /* ↓↓↓↓↓↓ 模板行数据填充 ↓↓↓↓↓↓ */
            rowDiff = templateRowIndexStartEnd - startRowStart + 1;
            Map<Integer, XWPFTableRow> templateRowsMap = new HashMap<>();
            for (int i = 0; i < rowDiff; i++) {
                templateRowsMap.put(i, table.getRow(startRowStart + i));
            }
            /* ↑↑↑↑↑↑ 模板行数据填充 ↑↑↑↑↑↑ */
            List<Map<String, Object>> dataMaps = (List<Map<String, Object>>) data;
            for (int i = 0; i < dataMaps.size(); i++) {
                Map<String, Object> rowData = dataMaps.get(i);
                //需要复制的具体行
                for (int j = 0; j < rowDiff; j++) {
                    XWPFTableRow templateRow = templateRowsMap.get(j);
                    //复制模板行
                    if (i != dataMaps.size() - 1) {
                        XWPFTableRow copyRow = deepCloneRow(table, templateRow);
                        fillRowData(copyRow, dataMaps.get(i + 1));
                        table.addRow(copyRow, startRowStart + ((i + 1) * rowDiff) + j);
                    }
                    //填充数据
                    if (i == 0) {
                        // 填充templateRow
                        fillRowData(templateRow, rowData);
                    }
                }
            }
        } catch (Exception e) {
            throw new RenderException("HackLoopTable for " + eleTemplate + "error: " + e.getMessage(), e);
        }
    }

    private int getTemplateRowIndex(XWPFTableCell tagCell) {
        XWPFTableRow tagRow = tagCell.getTableRow();
        return getRowIndex(tagRow);
    }

    private int getTemplateRowIndexByTemplateTag(XWPFTable table, int templateRowIndexStart, String templateTag, boolean clearTag) {
        int index = -1;
        List<XWPFTableRow> rows = table.getRows();
        for (int i = templateRowIndexStart; i < rows.size(); i++) {
            XWPFTableRow xwpfTableRow = rows.get(i);
            List<ICell> tableICells = xwpfTableRow.getTableICells();
            for (ICell tableICell : tableICells) {
                if (tableICell instanceof XWPFTableCell) {
                    String text = ((XWPFTableCell) tableICell).getText();
                    if (text.contains(templateTag)) {
                        //清空模板标记的内容
                        if (clearTag) {
                            String nText = text.replace(" + templateTag + ", "");
                            List<XWPFParagraph> paragraphs = ((XWPFTableCell) tableICell).getParagraphs();
                            for (XWPFParagraph paragraph : paragraphs) {
                                List<XWPFRun> runs = paragraph.getRuns();
                                for (int j = 0; j < runs.size(); j++) {
                                    if (j == 0) {
                                        runs.get(0).setText(nText, 0);//注意在模板上,在对应的位置上使用任务文本占位,此处才能在这个pos然后进行内容的替换
                                    } else {
                                        runs.get(j).setText("", 0);
                                    }
                                }
                            }
                        }
                        return i;
                    }
                }
            }
        }
        return index;
    }

    private int getRowIndex(XWPFTableRow row) {
        List<XWPFTableRow> rows = row.getTable().getRows();
        return rows.indexOf(row);
    }

}

插件使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Map<String, Object> exportMap = new HashMap<>();
/* ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  导出数据填充 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ */
//自定义多行循环
exportMap.put("studentsSelf", new ArrayList<Map<String, Object>>() );
    add(new HashMap<String, Object>() );
    add(new HashMap<String, Object>() );
}});
/* ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  导出数据填充 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ */
LoopMultiRowTableRenderPolicy multirowTableRenderPolicy = new LoopMultiRowTableRenderPolicy();
Configure configure = Configure.builder()
        .addPlugin('↓', rowTableRenderPolicy) //循环行数据绑定
        .bind("studentsSelf", multirowTableRenderPolicy) //自定义多行数据复制插件
        //区块对是默认插件,不需要主动申明
        .build();
XWPFTemplate template = XWPFTemplate.compile(templateIns, configure)
        .render(exportMap);
String fileName = DateUtil.format(new Date(), "yyyyMMddHHmmss") + "-导出.docx";
setFileName(response, fileName);//基础使用中的公共方法
//写回到响应流中
OutputStream out = response.getOutputStream();
BufferedOutputStream bos = new BufferedOutputStream(out);
template.write(bos);
bos.flush();
out.flush();
PoitlIOUtils.closeQuietlyMulti(template, bos, out);

填充结果

填充结果

本文由作者按照 CC BY 4.0 进行授权