Word 单条模板循环合并导出 Demo
Word 单条模板循环合并导出 Demo
1. 当前实现的两种思路
目前实现批量 Word 导出,主要有两种思路:
- 方案一:在模板中使用
{{?records}} ... {{/records}}做循环渲染 - 方案二:把模板设计成“单条记录模板”,批量时由 Java 多次渲染,再把多个 Word 合并
从表达上看,方案一更直接,因为模板本身就能描述“多条记录循环输出”。
但这两种方式并不是谁一定更高级,而是要看数据内容和模板复杂度。
2. 为什么没有直接统一用 {{?records}}
{{?records}} 这种循环块,从设计上看是成立的。
比如把下面这些内容:
{{?records}}- 表格主体
{{/records}}
都放在表格外层,看起来会更优雅一些。
但在实际 Word 模板编辑里,这种方式不总是稳定,原因不是模板“看起来对不对”,而是 Word 底层结构经常会把标签拆开。
常见会被拆到不同的:
- 段落
- 单元格
- run
这样一来,即使在 Word 页面上看着标签已经摆好了,poi-tl 真正解析时,底层 XML 结构也不一定是完整、连续、可识别的。
所以很容易出现这些问题:
Mismatched start/end tagsNo end iterable mark found
这类问题的麻烦点在于:
- 不是简单挪一下位置就一定能好
- 不是 Word 里肉眼看着正常就真的正常
- 模板稍微改动一下,又可能重新出问题
因此,在 纯文本、普通表格 的一些场景里,直接依赖 records 循环块未必是最稳的方案。
3. 为什么也不能简单认为“Java 循环 + 合并”一定更好
这里也要反过来说清楚:
“单条模板 + Java 循环 + 多个 Word 合并” 也不是没有代价。
它在 纯文本 场景下通常比较稳,但在 包含图片 的场景下,可能会出问题。
原因是:
- 单条渲染本身通常没问题
- 真正容易出问题的是“多个 Word 合并”这一步
如果合并只是简单复制段落、表格 XML,而没有把 Word 内部图片资源、关系引用一起正确复制,就可能出现:
- 后面页面图片丢失
- 多页都显示第一页的图片
- 图片关系错乱
这也是为什么之前在“接地电阻检测记录 V1”那类带图片的场景里,曾经出现过:
- 用 Java 循环生成多个单条 Word
- 合并后每页中的图片都变成第一页的
也正因为这个问题,后来才改用 {{?records}} 这种方式,在同一个模板实例里一次性渲染整批数据,尽量避开“多份 Word 合并时图片资源关系丢失”的坑。
所以这里不能简单下结论说:
- Java 循环合并一定比
{{?records}}稳 - 或者
{{?records}}一定比 Java 循环更好
正确理解应该是:
- 文本类记录:更适合“单条模板 + Java 循环 + 合并”
- 图片类记录:需要谨慎使用“先渲染再合并”,很多时候反而更适合
{{?records}}
4. 最终应该怎么选
更实用的判断标准是按场景选,而不是统一套一种写法。
4.1 更适合用 {{?records}} 的场景
建议优先考虑 {{?records}}:
- 一份模板里天然就是“多条记录列表”结构
- 每条记录里有图片
- 不希望处理多个 Word 合并时的图片资源问题
- 模板维护人员能够稳定控制循环标签位置
这类场景的核心收益是:
- 一次渲染生成一个最终 Word
- 避开多份 Word 合并带来的图片关系问题
4.2 更适合用“单条模板 + Java 循环 + 合并”的场景
建议优先考虑这个方案:
- 一条记录本来就对应一页或一份 Word
- 模板以固定表单、固定版式为主
- 主要内容是文字、表格、普通字段
- 没有图片,或者图片不是核心内容
- 希望后面按单条记录复用模板
这类场景的核心收益是:
- 单条模板更清晰
- 循环逻辑放在 Java 里更容易控
- 后期排查问题更直接
5. 本次 demo 的适用边界
这份 demo 主要适用于:
- 单条固定版式模板
- 纯文本或普通表格为主
- 批量时通过 Java 循环渲染
- 最终再合并 Word
这份 demo 不适合直接当成“图片场景通用模板”。
如果是像“接地电阻检测记录 V1”那种:
- 每条记录都有多张图片
- 图片必须一一对应且不能串页
- 合并结果还要保持稳定
那就不能想当然套这个 demo,而应该优先评估:
- 是否直接使用
{{?records}}一次性渲染整份文档 - 或者是否具备真正可靠的图片型 Word 合并能力
6. 这个 demo 的用途
这份 demo 不是单纯的文字说明,而是给后面开发时直接参考的案例。
但它的定位要说准确:
- 它是“纯文本 / 普通表格场景”的可复用案例
- 不是“所有 Word 批量导出场景”的统一答案
重点是:
- 遇到类似文本类需求时,可以直接照这个结构写
- 后面的人看文档时,能知道为什么这里用了 Java 循环合并
- 也能知道什么时候不该这么做
7. 最小可复用案例
7.1 Service 示例
package com.demo.service;
import com.deepoove.poi.XWPFTemplate;
import com.demo.entity.DemoRecord;
import org.apache.poi.xwpf.usermodel.BodyElementType;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class WordTemplateExportDemoService {
private static final String TEMPLATE_PATH = "doc-templates/demo-template.docx";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public byte[] exportMerged(List<DemoRecord> records) throws IOException {
if (records == null || records.isEmpty()) {
return new byte[0];
}
List<byte[]> documents = new ArrayList<>();
for (DemoRecord record : records) {
Map<String, Object> model = buildModel(record);
documents.add(renderSingle(model));
}
return mergeDocuments(documents);
}
private byte[] renderSingle(Map<String, Object> model) throws IOException {
try (InputStream inputStream = new ClassPathResource(TEMPLATE_PATH).getInputStream();
XWPFTemplate template = XWPFTemplate.compile(inputStream).render(model);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
template.write(outputStream);
return outputStream.toByteArray();
}
}
private byte[] mergeDocuments(List<byte[]> documents) throws IOException {
if (documents == null || documents.isEmpty()) {
return new byte[0];
}
XWPFDocument mergedDocument = null;
for (byte[] documentBytes : documents) {
if (documentBytes == null || documentBytes.length == 0) {
continue;
}
try (XWPFDocument current = new XWPFDocument(new ByteArrayInputStream(documentBytes))) {
if (mergedDocument == null) {
mergedDocument = new XWPFDocument(new ByteArrayInputStream(documentBytes));
} else {
appendDocument(mergedDocument, current);
}
}
}
if (mergedDocument == null) {
return new byte[0];
}
try (XWPFDocument document = mergedDocument;
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
document.write(outputStream);
return outputStream.toByteArray();
}
}
private void appendDocument(XWPFDocument target, XWPFDocument source) {
XWPFParagraph pageBreak = target.createParagraph();
pageBreak.setPageBreak(true);
for (IBodyElement element : source.getBodyElements()) {
if (element.getElementType() == BodyElementType.PARAGRAPH) {
XWPFParagraph sourceParagraph = (XWPFParagraph) element;
String text = sourceParagraph.getText();
boolean emptyParagraph = (text == null || text.trim().isEmpty())
&& !sourceParagraph.isPageBreak();
if (emptyParagraph) {
continue;
}
XWPFParagraph targetParagraph = target.createParagraph();
targetParagraph.getCTP().set(sourceParagraph.getCTP().copy());
} else if (element.getElementType() == BodyElementType.TABLE) {
XWPFTable sourceTable = (XWPFTable) element;
CTTbl copiedTable = (CTTbl) sourceTable.getCTTbl().copy();
target.getDocument().getBody().addNewTbl().set(copiedTable);
}
}
}
private Map<String, Object> buildModel(DemoRecord record) {
Map<String, Object> model = new HashMap<>();
model.put("projectName", defaultValue(record.getProjectName()));
model.put("deviceCode", defaultValue(record.getDeviceCode()));
model.put("lineName", defaultValue(record.getLineName()));
model.put("towerNo", defaultValue(record.getTowerNo()));
model.put("serviceTime", record.getServiceTime() == null ? "" : record.getServiceTime().format(DATE_FMT));
model.put("remark", defaultValue(record.getRemark()));
return model;
}
private String defaultValue(String value) {
return value == null ? "" : value;
}
}
这个案例里,最重要的是这三个方法:
buildModel():把单条记录转成模板字段renderSingle():渲染单条 WordmergeDocuments():把多个 Word 合并
这里把合并代码直接写在 demo 里,后面需要时直接复制最方便。
7.2 Controller 示例
@GetMapping("/export-demo")
public void exportDemo(HttpServletResponse response) throws IOException {
List<DemoRecord> records = demoService.listRecords();
byte[] bytes = wordTemplateExportDemoService.exportMerged(records);
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
response.setHeader("Content-Disposition", "attachment; filename=demo-export.docx");
response.getOutputStream().write(bytes);
}
这个 controller 不复杂,重点是 service 已经把“循环渲染 + 合并”都做完了。
8. 模板示例
模板里只放单条记录字段,不放复杂循环标签。
例如模板里这样写:
项目名称:{{projectName}}
设备编号:{{deviceCode}}
线路名称:{{lineName}}
杆塔号:{{towerNo}}
维保时间:{{serviceTime}}
备注:{{remark}}
不要在模板里写:
{{?records}}
...
{{/records}}
这个 demo 的关键,就是把循环从 Word 模板挪到 Java。
但这里要加一个前提:
这个结论主要针对“无图片或图片不是重点”的模板场景。
9. 分组导出案例
如果后期要按班组、项目之类字段分组导出,可以直接在这个 demo 基础上扩展。
9.1 Service 示例
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
public Map<String, byte[]> exportByGroup(List<DemoRecord> records) throws IOException {
Map<String, List<DemoRecord>> grouped = records.stream()
.collect(Collectors.groupingBy(
item -> item.getGroupName() == null ? "未分组" : item.getGroupName(),
LinkedHashMap::new,
Collectors.toList()));
Map<String, byte[]> result = new LinkedHashMap<>();
for (Map.Entry<String, List<DemoRecord>> entry : grouped.entrySet()) {
byte[] wordBytes = exportMerged(entry.getValue());
result.put(entry.getKey() + ".docx", wordBytes);
}
return result;
}
如果只是“每组合并成一个 Word”,写到这一步就够了。
如果还要打 ZIP,再在外层加 ZIP 处理。
但如果每条记录里包含多张图片,这里要非常谨慎,因为图片类记录在“先渲染、再合并”的链路里更容易出问题。
10. 在当前项目里的对应关系
如果按你现在项目的写法,对应关系可以这样看:
10.1 设备维保记录
更接近这份 demo 的适用范围:
- 查询数据:
DeviceMaintenanceService.search(...) - 单条模型构造:
DeviceMaintenanceService.buildModel(...) - 单条渲染:
DeviceMaintenanceService.renderSingle(...) - 按组组合并:
DeviceMaintenanceService.generateWordByTeam(...) - 分组打包:
DeviceMaintenanceService.generateZipByTeam(...)
这类记录主要是文字和表格,因此“单条模板 + Java 循环 + 合并”更合适。
10.2 接地电阻检测记录
这类场景要区别看待,因为它包含图片:
- 单条渲染通常没问题
- 真正风险在“多份 Word 合并”这一步
- 如果合并逻辑不能正确处理图片资源关系,就可能出现图片重复、串页或错乱
所以这也是为什么这类带图片的记录,后来会倾向于改用 {{?records}} 一次性渲染整批内容。
11. 后期复用时主要改哪些地方
以后如果复制这个 demo,一般只需要改这几处:
11.1 改模板路径
private static final String TEMPLATE_PATH = "doc-templates/your-template.docx";
11.2 改查询逻辑
List<YourRecord> records = yourMapper.search(keyword);
11.3 改 buildModel()
把模板占位字段改成你的业务字段。
11.4 改分组规则
如果要按班组、项目、线路分组,只改分组字段即可。
11.5 先判断是否包含图片
复用前最好先判断当前需求属于哪类:
- 如果主要是文字、表格:可以直接套这份 demo
- 如果每条记录包含多张图片:不要直接套,优先评估是否改用
{{?records}}
12. 这个 demo 最该记住的结论
后面再做类似需求,不要只记“某一种方案最好”,而要按场景选。
建议这样理解:
- 模板是纯文本/普通表格:优先考虑“单条模板 + Java 循环 + 合并”
- 模板里有多张图片:优先评估
{{?records}}一次性渲染 - 不要把这份 demo 当成图片型 Word 批量导出的通用答案
一句话总结:
这份 demo 适合文本类记录,不适合不加判断地直接套到图片类记录上。