基于 Word 模板占位符的动态文档生成实践(源码+保姆版)

news/2025/10/29 17:29:33/文章来源:https://www.cnblogs.com/blbl-blog/p/19174982

一、基于 Word 模板占位符的动态文档生成技术

💡 作者:古渡蓝按

个人微信公众号:微信公众号(深入浅出谈java)
感觉本篇对你有帮助可以关注一下,会不定期更新知识和面试资料、技巧!!!

📝 简介

在企业业务系统中,合同、工单、报告等 Word 文档往往格式固定但内容动态。传统硬编码方式开发效率低、维护成本高。
本文介绍一种高效、灵活的解决方案:通过预定义 Word 模板中的 ${KEY} 占位符,结合后端数据自动填充生成最终文档。该方法实现逻辑清晰、模板可由非技术人员维护,显著提升开发效率与系统可扩展性。以下是代码实现步骤和逻辑。


二、添加依赖:Apache POI

<!-- Apache POI for Word --><dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.2.4</version></dependency><!-- Optional: For logging --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>

三、制作代占位符的word 模板

打开需要生产的数据模板,在对应位置填写占位符,类似下图:占位符格式为:${XXXXX}

📌 注:占位符里面的必须和代码中的key 值一样

制作完成后,放到 src/main/resources/templates/ 目录下作为模板文件:

src/main/resources/templates/production_order_template.docx

图片示例

image-20251029155045954


四、编写核心逻辑

Controller 代码

@Slf4j
@RestController
public class ProductionOrderController {@Resourceprivate WordGeneratorService productionOrderService;@GetMapping("/api/generate-word")public void generateWord(@RequestParam Long id, HttpServletResponse response) throws IOException {ProductionOrder order = new ProductionOrder();byte[] docBytes = productionOrderService.generateProductionOrderDoc(order);// 设置正确的 Content-Typeresponse.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");response.setContentLength(docBytes.length);// ✅ 安全设置带中文的文件名(关键!)String filename = "生产任务单_" + id + ".docx";String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");// 使用 filename* 语法(RFC 5987):支持 UTF-8 文件名response.setHeader("Content-Disposition","attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename);// 写入响应体response.getOutputStream().write(docBytes);response.getOutputStream().flush();}@GetMapping("/api/generate-word2")public void generateWord2(@RequestParam String no, HttpServletResponse response) throws IOException {// 这里的ProductionOrder 可以换成自己对应的实体或者需要填写到数据库的对象// 正常逻辑是,这个order 是需要查后台数据,然后返回order对象,再在后续做模板和值 映射,类似下列代码,// 这一步最好放到实现类去写,这里只是为了方便//TODO:List<ProductionOrder> getProductDataList = this.list(//        new LambdaQueryWrapper<ProductionOrder>()//                .eq(ProductionOrder::getNo, no));ProductionOrder order = new ProductionOrder();// 改用模板生成byte[] docBytes = productionOrderService.generateFromTemplate(order);// 设置正确的 Content-Typeresponse.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");response.setContentLength(docBytes.length);// ✅ 安全设置带中文的文件名(关键!)String filename = "生产任务单_" + no + ".docx";String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");// 使用 filename* 语法(RFC 5987):支持 UTF-8 文件名response.setHeader("Content-Disposition","attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename);// 写入响应体response.getOutputStream().write(docBytes);response.getOutputStream().flush();}
}

Service层核心实现代码

📌 注:这里就省去了接口层(需要可以自己加),直接放置的核心方法

@Service
public class WordGeneratorService {public byte[] generateProductionOrderDoc(ProductionOrder order) {try (XWPFDocument document = new XWPFDocument()) {// 标题XWPFParagraph titlePara = document.createParagraph();titlePara.setAlignment(ParagraphAlignment.CENTER);XWPFRun titleRun = titlePara.createRun();titleRun.setText("生产任务单申请表");titleRun.setFontSize(16);titleRun.setBold(true);// 创建表格(20列模拟原表宽度,实际按内容合并)XWPFTable table = document.createTable(5, 4);table.setWidth("100%");// 第一行:客户单位 & 订单号setCellText(table.getRow(0).getCell(0), "客户单位:");setCellText(table.getRow(0).getCell(1), order.getCustomer());setCellText(table.getRow(0).getCell(2), "订单号/合同编号:");setCellText(table.getRow(0).getCell(3), order.getOrderNo());// 第二行:产品名称 & 型号setCellText(table.getRow(1).getCell(0), "产品名称:");setCellText(table.getRow(1).getCell(1), order.getProductName());setCellText(table.getRow(1).getCell(2), "产品型号:");setCellText(table.getRow(1).getCell(3), order.getModel());// 第三行:规格(电压、电流、数量)setCellText(table.getRow(2).getCell(0), "规格");setCellText(table.getRow(2).getCell(1), "电压:" + order.getVoltage());setCellText(table.getRow(2).getCell(2), "电流:" + order.getCurrent());setCellText(table.getRow(2).getCell(3), "数量:" + order.getQuantity());// 第四行:生产周期setCellText(table.getRow(3).getCell(0), "生产周期");setCellText(table.getRow(3).getCell(1), "计划出货日期:" + order.getPlannedShipDate());setCellText(table.getRow(3).getCell(2), "销售项目人:");setCellText(table.getRow(3).getCell(3), order.getSalesPerson());// 第五行:备注或其他setCellText(table.getRow(4).getCell(0), "其他要求:");table.getRow(4).getCell(1).getParagraphs().get(0);// 合并单元格(可选,简化处理)// 实际复杂表格建议用模板或 Apache POI 高级合并ByteArrayOutputStream out = new ByteArrayOutputStream();document.write(out);return out.toByteArray();} catch (Exception e) {throw new RuntimeException("生成 Word 失败", e);}}private void setCellText(XWPFTableCell cell, String text) {cell.setText(text);// 可选:设置字体for (XWPFParagraph p : cell.getParagraphs()) {for (XWPFRun r : p.getRuns()) {r.setFontFamily("宋体");r.setFontSize(10);}}}//方式二private static final String TEMPLATE_PATH = "templates/production_order_template.docx";public byte[] generateFromTemplate(ProductionOrder order) {try {// 1. 加载模板ClassPathResource resource = new ClassPathResource(TEMPLATE_PATH);try (InputStream is = resource.getInputStream();ByteArrayOutputStream out = new ByteArrayOutputStream()) {XWPFDocument document = new XWPFDocument(is);// 2. 构建数据映射Map<String, String> data = new HashMap<>();data.put("customer", safeStr(order.getCustomer()));data.put("orderNo", safeStr(order.getOrderNo()));data.put("workOrderNo", safeStr(order.getWorkOrderNo()));data.put("productName", safeStr(order.getProductName()));data.put("model", safeStr(order.getModel()));data.put("voltage", safeStr(order.getVoltage()));data.put("current", safeStr(order.getCurrent()));data.put("quantity", safeStr(order.getQuantity() != null ? order.getQuantity().toString() : ""));data.put("plannedShipDate", safeStr(order.getPlannedShipDate()));data.put("salesPerson", safeStr(order.getSalesPerson()));//如果你希望某些字段只显示“√”表示选中,可以在 Java 中这样处理:data.put("hasEmbeddedSeal", order.isEmbeddedSeal() ? "√" : "");// 3. 替换所有段落中的占位符replaceInParagraphs(document.getParagraphs(), data);// 4. 替换表格中的占位符for (XWPFTable table : document.getTables()) {for (XWPFTableRow row : table.getRows()) {for (XWPFTableCell cell : row.getTableCells()) {replaceInParagraphs(cell.getParagraphs(), data);}}}// 5. 输出为字节数组document.write(out);return out.toByteArray();}} catch (Exception e) {throw new RuntimeException("生成 Word 文档失败", e);}}/*** 替换段落中的占位符*/private void replaceInParagraphs(List<XWPFParagraph> paragraphs, Map<String, String> data) {for (XWPFParagraph para : paragraphs) {for (XWPFRun run : para.getRuns()) {if (run != null && run.getText(0) != null) {String text = run.getText(0);String replaced = replacePlaceholders(text, data);if (!text.equals(replaced)) {run.setText(replaced, 0);}}}}}/*** 使用正则替换 ${key} 为 value*/private String replacePlaceholders(String text, Map<String, String> data) {Pattern pattern = Pattern.compile("\\$\\{([^}]+)\\}");Matcher matcher = pattern.matcher(text);StringBuffer sb = new StringBuffer();while (matcher.find()) {String key = matcher.group(1);String replacement = data.getOrDefault(key, matcher.group(0)); // 未找到则保留原样matcher.appendReplacement(sb, replacement == null ? "" : Matcher.quoteReplacement(replacement));}matcher.appendTail(sb);return sb.toString();}private String safeStr(String str) {return str == null ? "" : str;}
}

五、注意事项

⚠️ 1、占位符被拆分问题(未能正确显示数值)

Word 会因格式变化将 ${NO} 拆成多个 Run(如 ${N + O}),导致无法匹配。这里不要用文本框或艺术字等

Apache POI 在读取 Word 文档时,会将文本按格式(字体、颜色、加粗等)拆分成多个 XWPFRun 对象。

例如下面图片,编号未能正确显示

image-20251029163259173

❌ 问题场景:

如果在 Word 中输入 ${NO} 时:

  • 中间不小心按了方向键、空格、Backspace
  • 或对部分字符设置了格式(比如只加粗了 N
  • 或从其他地方复制粘贴过来

那么 Word 内部可能存储为:

Run1: "${N"
Run2: "O}"

而替换逻辑是 Run 处理

for (XWPFRun run : para.getRuns()) {String text = run.getText(0); // 只拿到 "${N" 或 "O}"// 无法匹配完整 "${NO}"
}

结果:${NO} 没有被识别,也就不会被替换!

而其他占位符(如 ${SJBBH})可能是一次性输入的,所以在一个 Run 里,能正常替换。

解决方案

  • 在模板中一次性输入完整占位符,避免中途格式调整。(不要中途按方向键、不要设置局部格式)

    💡 技巧:可以先输入 ABC,确认它在一个 Run 里(比如全选后统一加粗),再替换成 ${NO}

  • 或使用更高级的跨 Run 合并替换算法(实现复杂)。

    当前逻辑只处理单个 Run,无法处理被拆分的占位符。可以改用更健壮的方案:

    方案 A:合并段落所有文本,整体替换(简单但会丢失格式,不推荐,会破坏原有样式)

    方案 B:使用递归或缓冲区拼接 Run(复杂),但对大多数项目来说,方法 1(规范模板输入)是最高效、最可靠的

🔧 调试技巧:如果替换失败,可临时打印 run.getText(0) 查看实际文本分段。

  • 次要可能原因排查

    • ✅ 1. 检查 Java 实体类字段是否正确

    • 2. 检查 Word 模板中是否真的是 ${NO}(大小写敏感)

    • 检查是否在表格 or 段落中?


⚠️2、使用方式一,返回的是zip文件而不是word 文件

核心原因:

.docx 文件本质上就是一个 ZIP 压缩包

  • Microsoft Office 2007 及以后的 .docx.xlsx.pptx 文件都采用 Open XML 格式
  • 这种格式实际上是将 XML、图片、样式等文件打包成一个 ZIP 压缩包,只是扩展名改成了 .docx
  • 当用代码生成.docx但没有正确设置 HTTP 响应头(Content-Type 和 Content-Disposition)
    • 浏览器无法识别这是 Word 文档
    • 会根据文件内容的“真实类型”(ZIP)来处理
    • 于是自动下载为 .zip 文件,或提示“文件损坏”

解决方案

  • 设置正确的响应头
HttpHeaders headers = new HttpHeaders();// 1. 设置 Content-Type(MIME 类型)
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"));// 2. 设置 Content-Disposition(告诉浏览器这是附件,且文件名是 .docx)
headers.setContentDispositionFormData("attachment", "生产任务单.docx");return new ResponseEntity<>(docBytes, headers, HttpStatus.OK);

❌ 常见错误写法(会导致 ZIP 下载):

// 错误1:Content-Type 写成 application/zip 或 application/octet-stream
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); // ❌// 错误2:文件名没有 .docx 后缀
headers.setContentDispositionFormData("attachment", "report"); // ❌ 下载为 report.zip// 错误3:文件名包含非法字符(如 / \ : * ? " < > |)
headers.setContentDispositionFormData("attachment", "生产/任务单.docx"); // ❌ 可能被截断或变 ZIP

🔧 额外检查点:

  1. 确认生成的字节数组确实是合法 .docx

    • docBytes 保存到本地文件:Files.write(Paths.get("test.docx"), docBytes);
    • 用 Word 能正常打开吗?如果打不开 → 说明生成逻辑有误(不是 ZIP 问题,是文件损坏)
  2. 不要用 application/zipapplication/octet-stream
    即使内容是 ZIP 结构,也必须声明为 Word 的 MIME 类型!


⚠️3、使用浏览器直接请求报错

报错示例:

java.lang.IllegalArgumentException: The Unicode character [生] at code point [29,983] cannot be encoded as it is outside the permitted range of 0 to 255

根本原因
在设置 HTTP 响应头(特别是 Content-Disposition 文件名)时,直接使用了包含中文字符(如“生产任务单.docx”)的字符串,而 Tomcat 在处理 HTTP 响应头时,默认使用 ISO-8859-1 编码(只支持 0–255 的字节范围),无法表示中文字符(Unicode 超出 255),于是抛出异常。

✅ 正确解决方案:对文件名进行 RFC 5987 / RFC 2231 兼容的编码

HTTP 协议规定:响应头中的非 ASCII 字符必须进行编码。推荐使用 filename\* 语法(带编码声明)

// 设置正确的 Content-Typeresponse.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");response.setContentLength(docBytes.length);// ✅ 安全设置带中文的文件名(关键!)String filename = "生产任务单_" + id + ".docx";String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");// 使用 filename* 语法(RFC 5987):支持 UTF-8 文件名response.setHeader("Content-Disposition","attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename);// 写入响应体response.getOutputStream().write(docBytes);response.getOutputStream().flush();

六、接口验证

可以访问接口:http://127.0.0.1:8199/api/generate-word2?no=27202SCRW250006

这样你的浏览器就会弹出下载页面,并且获取一个填充数据的word 文档

image-20251029164051702

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/950103.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

坤驰科技荣膺国家级专精特新“小巨人”企业

近日,北京市经济和信息化委员会公布了北京市入选国家级专精特新 “小巨人” 企业名单 ,北京坤驰科技有限公司(以下简称“坤驰科技”)凭借在高端信号采集与处理领域的突出创新力和产业化能力,顺利通过遴选,荣膺国…

读书笔记:Oracle组合分区:像俄罗斯套娃一样管理数据

我们的文章会在微信公众号IT民工的龙马人生和博客网站( www.htz.pw )同步更新 ,欢迎关注收藏,也欢迎大家转载,但是请在文章开始地方标注文章出处,谢谢! 由于博客中有大量代码,通过页面浏览效果更佳。本文为个人学…

自动对焦技术:TGV视觉检测方案中的关键

玻璃通孔(TGV)工艺在半导体封装中应用广泛,但在检测过程中面临诸多挑战, 主要体现在以下几点: 1、精度要求高 TGV技术的精度要求极高,通常是微米级。为了确保电气性能和信号传输的稳定性,任何微小的形变或尺寸偏…

红外热像仪 热成像相机 即插即用多场景适配

红外热像仪 热成像相机 即插即用多场景适配Red Eye Camera 是一款基于红外阵列高精度温度传感器与先进软件算法的非接触式热成像仪,可实时对视场范围内物体进行清晰红外成像。该设备具备512384像素的高分辨率,温度灵…

详细介绍:K8s学习笔记(十一) service

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

vn.py的日志问题

vn.py的日志问题使用新版 vn.py 调试时, 发现没有日志, 然后写了下面一段代码跟踪:from vnpy.trader.setting import SETTINGS from vnpy.event import EventEngine from vnpy.trader.engine import MainEngine fro…

Oracle故障处理:create table 引起select语句hang住出现library cache lock事件

我们的文章会在微信公众号IT民工的龙马人生和博客网站( www.htz.pw )同步更新 ,欢迎关注收藏,也欢迎大家转载,但是请在文章开始地方标注文章出处,谢谢! 由于博客中有大量代码,通过页面浏览效果更佳。Oracle故障处…

OSI中七层模型

OSI中七层模型OSI中七层模型 1.物理层: 在物理媒介上传输原始的比特流。它定义了电气,机械,过程和功能规范,以激活。维持和断开物理链路。 关心的问题:电压大小,引脚数量,电缆类型,传输速率,信号同步等 协议/…

2025 年西安苹果手机维修公司最新推荐榜,聚焦技术实力与用户口碑深度解析

引言 随着西安苹果设备保有量年增 18%,维修需求同步攀升,但行业仍存在配件掺假率 12%、隐性收费占比 23% 等乱象。为破解选择难题,本次推荐榜联合该委员会开展专项测评,采用 “三维九维度” 评估体系:技术维度涵盖…

2025 年西安苹果电脑维修最新推荐榜,技术实力与市场口碑深度解析

引言 苹果电脑的精密构造与高端配置,使其维修对技术与服务的专业性要求极高。但西安维修市场仍存在配件以次充好、报价虚高、维修流程不透明等问题,据电子商会消费电子售后服务专业委员会 2024 年测评数据显示,西安…

Awesome GitHub Copilot:超级定制化AI编程助手工具集

Awesome GitHub Copilot是一个精心策划的GitHub Copilot定制化资源库,包含丰富的提示词、自定义指令和聊天模式,涵盖多种编程语言、开发框架和云服务平台,帮助开发者显著提升编程效率和代码质量。Awesome GitHub Co…

跟着视频学,从0开始学PostgreSQL数据库

首先,要清楚一个问题“为什么要学习PostgreSQL数据库”?PostgreSQL有很多优点,比如开源、免费、功能强大等等,基于这些优点,我国众多数据库厂商都选择PostgreSQL再次开发自己的产品,国内使用PG数据库的企业和项目…

SSD和HDD存储应该如何选择?

选择 SSD(固态硬盘) 和 HDD(机械硬盘) 的存储类型主要取决于你的需求、预算以及使用场景。以下是详细的比较和建议,帮助你在不同场景下做出最佳选择。1. SSD 和 HDD 的基本介绍 1.1 SSD(固态硬盘)特点:使用闪存…

基于大语言模型的浏览器自动化:playwright+browser-use

基于大语言模型的浏览器自动化:playwright+browser-use 1、安装nodejs https://nodejs.org/zh-cn/download 下载 node-v24.11.0-x64.msi 2、安装 playwrightnpx playwright install 3、安装 browser-use 包 首先需要安…

Git克隆远程仓库后,本地无感切换分支工作的验证

Git客户端克隆远程仓库后如果本地想切换到feature/add-info分支工作,直接切换即可。 关于无感切换的操作,说明如下: 在 Git 2.23+ 版本中,新增了 “隐含创建本地分支” 的功能:当你执行 git checkout feature/add…

2025 年锰钢编织筛网厂家最新推荐榜,技术实力与市场口碑深度解析,筛选优质靠谱供应商振动/滚筒/平筛/黑钢锰钢编织筛网公司推荐

引言 为精准筛选出 2025 年锰钢编织筛网领域优质靠谱的供应商,本次推荐榜测评工作联合矿业装备协会、金属制品工业协会共同开展。测评过程严格遵循 “多维度、重数据、强验证” 原则,从企业综合实力与产品核心竞争力…

P7353 [2020-2021 集训队作业] Tom Jerry 题解

Sol 注意到 T 想赢必须一步一步缩小 J 的移动空间,所以 T 最优只会移动到割点来缩小 J 的移动空间最终让 J 无处可移。 所以我们考虑建出原图的圆方树。 考虑对于一组询问,把 \(a\) 提起来作为根,那么设 \(b\) 是 \…

痞子衡嵌入式:在i.MXRTxxx下使能DMA链式传输可达到SPI从设备接收速率上限50Mbps

大家好,我是痞子衡,是正经搞技术的痞子。今天痞子衡给大家介绍的是i.MXRT下使能DMA链式传输可达到SPI从设备接收速率上限50Mbps。最近痞子衡在帮一个 RT600 的 AR 眼镜客户优化 SPI 从设备接收数据的速率,我们知道 …

国产LTCC低通滤波器HT-LFCG-530+实测:完美替代LFCG-530+,5G/WiFi6/车规全场景

国产LTCC低通滤波器HT-LFCG-530+实测:完美替代LFCG-530+,5G/WiFi6/车规全场景封装对比 ① 尺寸:5.03.01.1 mm,与原版游标卡尺测量完全一致,焊盘0.450.65 mm,0.65 mm间距,8Pin,钢网不用改。 ② 重量:16.3 mg v…