poi-tl参考文档:https://deepoove.com/poi-tl/
摘要: 案例主要涉及了 文本、图片、多张图片 渲染的功能,如需其他功能可参看poi-tl官方文档。
一、制作word模版
- 使用WPS或者office制作出word模版。示例:模版放在 resources 目录下,图片可放在 resources/static 目录下。
- resources:SpringBoot中的类路径加载,通过ClassPathResource或类加载器可以轻松获取文件流,无需编写绝对路径。
- resources/static:SpringBoot的默认静态资源目录,通常用来存放CSS、JS和图片。

- 数据渲染后导出的word文档示例:

二、编写代码
-
首先创建一个SpringBoot项目。
-
pom文件引入相关依赖。
java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>ExportWord</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>ExportWord</name>
<description>ExportWord</description>
<properties>
<java.version>17</java.version>
<poi-tl.version>1.12.2</poi-tl.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Plus (通常不含 POI,但为保险排除) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.deepoove</groupId>
<artifactId>poi-tl</artifactId>
<version>${poi-tl.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 编写工具类------Response 响应类。
java
@Data
public class Response<T> implements Serializable
{
private static final long serialVersionUID = 1L;
/** 成功 */
public static final int SUCCESS = 200;
/** 失败 */
public static final int FAIL = 500;
private int code;
private String msg;
private T data;
public static <T> Response<T> ok()
{
return restResult(null, SUCCESS, null);
}
public static <T> Response<T> ok(T data)
{
return restResult(data, SUCCESS, null);
}
public static <T> Response<T> ok(T data, String msg)
{
return restResult(data, SUCCESS, msg);
}
public static <T> Response<T> fail()
{
return restResult(null, FAIL, null);
}
public static <T> Response<T> fail(String msg)
{
return restResult(null, FAIL, msg);
}
public static <T> Response<T> fail(T data)
{
return restResult(data, FAIL, null);
}
public static <T> Response<T> fail(T data, String msg)
{
return restResult(data, FAIL, msg);
}
public static <T> Response<T> fail(int code, String msg)
{
return restResult(null, code, msg);
}
private static <T> Response<T> restResult(T data, int code, String msg)
{
Response<T> apiResult = new Response<>();
apiResult.setCode(code);
apiResult.setData(data);
apiResult.setMsg(msg);
return apiResult;
}
public static <T> Boolean isError(Response<T> ret)
{
return !isSuccess(ret);
}
public static <T> Boolean isSuccess(Response<T> ret)
{
return Response.SUCCESS == ret.getCode();
}
}
- 编写实体类。
java
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("safety_briefing")
public class SafetyBriefing {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
// 单位名称
private String unitName;
// 作业名称
private String jobName;
// 编号
private String sno;
// 作业地点
private String jobPlace;
// 组织者
private String organizer;
// 交底时间
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime briefingTime;
// 交底地点
private String place;
// 作业计划
private String contentPart1;
// 安全交底内容
private String contentPart2;
// 公司员工签字
private String companyStaffList;
// 作业负责人
private String projectManager;
// 签字日期
private LocalDate signDate;
// 创建时间
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
// 更新时间
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;
}
- 编写 controller 类。
java
@RestController
@RequestMapping("/export")
@RequiredArgsConstructor
public class ExportWordController {
private final SafetyBriefingWordService wordService;
/**
* 动态生成word文档
* 1. 获取word文件模版
* 2. 使用 poi-tl 渲染并生成新的 word 文档
* 3. 返回回显数据与下载链接
* @return
*/
@PostMapping("/word/generate")
public Response<?> generateWord(@RequestBody SafetyBriefing generateForm) {
GenerateWordResult generate = wordService.generate(generateForm);
return Response.ok(generate);
}
/**
* 下载生成后的文档(下载成功后会清理对应临时文件)
*/
@GetMapping("/word/download/{fileId}")
public void download(@PathVariable String fileId, HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
response.setHeader("Pragma", "no-cache");
response.setCharacterEncoding("UTF-8");
wordService.writeToResponseAndCleanup(fileId, response);
}
}
- 编写 service 类。
java
@Service
@Slf4j
@RequiredArgsConstructor
public class SafetyBriefingWordService {
private static final String TEMPLATE_CLASSPATH = "classpath:/交底记录表.docx";
private static final String STATIC_CLASSPATH_PREFIX = "classpath:/static/";
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final ResourceLoader resourceLoader;
private final WordFileStore store = new WordFileStore();
public GenerateWordResult generate(SafetyBriefing form) {
String fileId = UUID.randomUUID().toString().replace("-", "");
Path outPath = createTempDocxPath(fileId);
Resource templateResource = resourceLoader.getResource(TEMPLATE_CLASSPATH);
if (!templateResource.exists()) {
throw new IllegalStateException("找不到模板:" + TEMPLATE_CLASSPATH + ",请将交底记录表.docx放到resources根目录");
}
Map<String, Object> data = toRenderData(form);
try (XWPFTemplate template = XWPFTemplate.compile(templateResource.getInputStream()).render(data);
FileOutputStream fos = new FileOutputStream(outPath.toFile())) {
template.write(fos);
} catch (Exception e) {
log.error("word渲染失败,companyStaffList={}", form.getCompanyStaffList(), e);
throw new IllegalStateException("生成word失败:" + e.getMessage(), e);
}
store.put(fileId, outPath);
return new GenerateWordResult(fileId, "/export/word/download/" + fileId, form);
}
private Map<String, Object> toRenderData(SafetyBriefing form) {
Map<String, Object> map = new HashMap<>();
map.put("unitName", form.getUnitName());
map.put("jobName", form.getJobName());
map.put("sno", form.getSno());
map.put("jobPlace", form.getJobPlace());
map.put("organizer", form.getOrganizer());
map.put("briefingTime", form.getBriefingTime() == null ? "" : DT.format(form.getBriefingTime()));
map.put("place", form.getPlace());
map.put("contentPart1", form.getContentPart1());
map.put("contentPart2", form.getContentPart2());
map.put("projectManager", loadStaffSignature(form.getProjectManager()));
// 动态员工签名(poi-tl循环标签数据)
List<Map<String, Object>> staffRows = buildStaffRows(form.getCompanyStaffList());
map.put("signImageList", staffRows);
// 向后兼容:若模板是固定占位符写法,仍可显示前两张
map.put("companyStaff", staffRows.isEmpty() ? null : staffRows.get(0).get("companyStaffSign"));
map.put("companyStaff1", staffRows.size() > 0 ? staffRows.get(0).get("companyStaffSign") : null);
map.put("companyStaff2", staffRows.size() > 1 ? staffRows.get(1).get("companyStaffSign") : null);
LocalDate signDate = form.getSignDate();
if (signDate != null) {
map.put("year", signDate.getYear());
map.put("month", signDate.getMonthValue());
map.put("day", signDate.getDayOfMonth());
} else {
map.put("year", "");
map.put("month", "");
map.put("day", "");
}
return map;
}
private List<Map<String, Object>> buildStaffRows(String companyStaffListStr) {
List<String> staffNames = parseSignNames(companyStaffListStr);
if (staffNames.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> rows = new ArrayList<>();
for (String staffName : staffNames) {
Map<String, Object> row = new HashMap<>();
row.put("staffName", staffName);
PictureRenderData sign = loadStaffSignature(staffName);
row.put("companyStaffSign", sign);
rows.add(row);
}
return rows;
}
private List<String> parseSignNames(String companyStaff) {
if (companyStaff == null || companyStaff.isBlank()) {
return Collections.emptyList();
}
return Arrays.stream(companyStaff.split("[,;|,;]"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
private PictureRenderData loadStaffSignature(String staffName) {
String filename = normalizePngName(staffName);
Resource resource = resourceLoader.getResource(STATIC_CLASSPATH_PREFIX + filename);
if (!resource.exists()) {
log.warn("员工签名图片不存在:{}", filename);
return null;
}
try (InputStream is = resource.getInputStream()) {
return Pictures.ofStream(is, PictureType.PNG)
.size(80, 40)
.create();
} catch (Exception e) {
log.error("读取员工签名图片失败:{}", filename, e);
return null;
}
}
private String normalizePngName(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
return "";
}
return name.endsWith(".png") ? name : name + ".png";
}
private static Path createTempDocxPath(String fileId) {
try {
String tmpDir = System.getProperty("java.io.tmpdir");
Path dir = Path.of(tmpDir, "ExportWord");
Files.createDirectories(dir);
return dir.resolve(fileId + ".docx");
} catch (IOException e) {
throw new IllegalStateException("创建临时目录失败:" + e.getMessage(), e);
}
}
public void writeToResponseAndCleanup(String fileId, HttpServletResponse response) throws IOException {
Path path = store.remove(fileId)
.orElseThrow(() -> new FileNotFoundException("未找到可下载文件,可能已下载或已过期。fileId=" + fileId));
String filename = "safety-briefing-" + fileId + ".docx";
String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
try (FileInputStream fis = new FileInputStream(path.toFile());
ServletOutputStream os = response.getOutputStream()) {
fis.transferTo(os);
os.flush();
} finally {
Files.deleteIfExists(path);
}
}
}
- 编写 GenerateWordResult 类。
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GenerateWordResult {
private String fileId;
private String downloadUrl;
private SafetyBriefing data;
}
- 使用接口测试工具如PostMan或Apifox测试接口。