Word 单条模板循环合并导出 Demo

lishihuan大约 9 分钟

Word 单条模板循环合并导出 Demo

1. 当前实现的两种思路

目前实现批量 Word 导出,主要有两种思路:

  • 方案一:在模板中使用 {{?records}} ... {{/records}} 做循环渲染
  • 方案二:把模板设计成“单条记录模板”,批量时由 Java 多次渲染,再把多个 Word 合并

从表达上看,方案一更直接,因为模板本身就能描述“多条记录循环输出”。

但这两种方式并不是谁一定更高级,而是要看数据内容和模板复杂度。


2. 为什么没有直接统一用 {{?records}}

{{?records}} 这种循环块,从设计上看是成立的。

比如把下面这些内容:

  • {{?records}}
  • 表格主体
  • {{/records}}

都放在表格外层,看起来会更优雅一些。

但在实际 Word 模板编辑里,这种方式不总是稳定,原因不是模板“看起来对不对”,而是 Word 底层结构经常会把标签拆开

常见会被拆到不同的:

  • 段落
  • 单元格
  • run

这样一来,即使在 Word 页面上看着标签已经摆好了,poi-tl 真正解析时,底层 XML 结构也不一定是完整、连续、可识别的。

所以很容易出现这些问题:

  • Mismatched start/end tags
  • No 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():渲染单条 Word
  • mergeDocuments():把多个 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 适合文本类记录,不适合不加判断地直接套到图片类记录上。