1. 跟“文档生成”打交道前,先想清楚业务要的到底是什么
前段时间接了一个合同生成的需求,业务方丢过来一份带红头、带骑缝章位置、带几十个合并单元格的Word模板,说要把系统里的订单数据填进去,一次生成几百份。当时团队里几个人的第一反应都是:用POI直接画。可真开始写代码才发现,从零开始用POI搭建一个和模板完全一致的版式,工作量比想象中大得多,而且业务方隔三差五就要微调一下表头、换个字体、加一行备注,每次调整都要跟着改代码,光维护就耗掉不少人力。
后来我把这类需求的经验重新梳理了一遍,发现“Java根据模板生成doc文件”这件事,大多数项目真正的诉求并不是“做一个Word文档”,而是“把动态数据填进一份版式固定的文档里,并且让填充之后的结果看起来和原始模板几乎一模一样”。这里有一个很容易被忽略的核心:模板是业务方维护的,代码是程序员维护的,如果两者耦合在一起,任何版式调整都会变成一场灾难。
1.1 这类需求的共性:版式固定、数据变化
我总结下来,日常碰到的模板生成场景无非这几类:
- 合同、协议、确认单:订单信息、客户姓名、金额、日期需要每次填充,其余条款固定。
- 报表、验收单、体检报告:大量表格,表头固定、行数随数据变化。
- 通知、证明、回执:抬头、正文、落款固定,只有姓名、编号、事由变化。
- 封皮、证书、奖状:版式精美,文字少,但对字体和间距要求极高。
这些场景有几个共同点:一是版式的美观程度直接决定交付质量,二是数据量通常不大但字段很多,三是模板本身会频繁调整。理解了这一点,“技术选型”就等于完成了一半——你要找的不是一个生成Word的库,而是一个把模板和数据分离、让非技术人员也能维护版式的方案。
1.2 常见的三种“跑偏”做法
先说说我见过最容易走偏的方案,如果你正准备动手,可以先停在原地看一眼。
第一种是纯代码从零绘制文档。用POI创建段落、创建表格、逐格塞数据。这种方式的缺点是代码量和文档复杂度成正比,一个含嵌套表格、合并单元格、页眉页脚的模板,写下来几百行代码很正常,而且一旦模板改版,代码推倒重来。优点几乎只有一条:不依赖模板文件,适合文档结构简单的极少数场景。
第二种是先转HTML,再让Word打开。有人觉得HTML写起来方便,于是把数据渲染成HTML,再通过Word另存为doc。结果就是兼容性惨不忍睹,表格宽度、分页、页眉页脚全部错乱,Word里打开几乎没法看。这个方案我在实际项目中试用过之后就彻底放弃了。
第三种是直接修改模板文件里的二进制内容或者粗暴进行字符串替换。短小的文本替换看似能用,一旦遇到格式、图片、表格循环,就会把文件结构改坏,最后出来一份“损坏的文档”。
1.3 真正合理的目标
所以,你真正应该追求的目标是:模板由业务方用Word编辑,程序员只负责提供数据,生成时把数据填入模板的指定位置,最后输出一份保持原版式的文档。这也是我后面几节所有方案展开的出发点。
2. 为什么选型必须从doc和docx的底层差异说起
很多人只知道DOC能打开,DOCX也能打开,却不知道这两种格式背后的实现机制完全不同。这直接决定了你能不能用某套方案稳定生成,也决定了生成的文件在各种Office软件里打开时会不会出现样式错乱。
2.1 doc的老二进制格式与POI的窘境
.doc是微软早期的OLE2复合文档格式,本质是一个二进制容器,里面藏着各种复杂的流和结构。解析这种格式的难度很高,类似在一个没有目录的仓库里找东西,你得先搞清楚每个流的结构,再逐字节读取。
Java生态里,POI用HWPF来处理.doc,但真正做过的人都知道,HWPF对复杂样式、表格、图片的支持比较弱,很多场景下API不完整或者行为诡异。我见过不少项目在处理老doc模板时,明明只是一个段落替换,结果字体全变、表格错位,找不出问题在哪。所以面对老doc,我的第一反应永远是:先想办法把它转成docx再说,而不是硬刚二进制格式。
2.2 docx本质是压缩包里的XML
.docx是Office 2007之后推出的基于Open XML标准的新格式。把任何一个docx文件复制一份,改后缀为.zip,解压后你会看到非常清晰的结构:
- word/document.xml:正文所有段落、表格、文本内容
- word/styles.xml:整篇文档的样式定义
- word/media/:文档中的图片文件
- [Content_Types].xml:文件类型声明
- word/header*.xml、word/footer*.xml:页眉页脚
这意味着,docx生成的核心逻辑变成了“解析XML、填充数据、写回压缩包”。对于Java来说,POI的XWPF组件专门处理docx,API相对完整,还有一批基于XWPF的上层封装,把模板填充这件事做得非常顺手。这也是为什么当前几乎所有开源方案都围绕docx展开。
2.3 主流方案的原理对比
我把市面上常见的几类方案放到一起做了对比,方便你直接拿去做选型依据。
| 方案 | 实现原理 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| POI原生XWPF | 直接操作docx的段落、表格、图片对象 | 灵活度最高,可自由控制文档结构 | 编码量大,模板样式复用难 | 动态生成复杂文档、无模板可依 |
| poi-tl | 在XWPF之上封装模板占位符 | 语法简单,模板与代码解耦,表格循环和图片插入方便 | 需要模板遵循占位符规范 | 绝大多数“固定模板+可变数据”的场景 |
| FreeMarker + docx XML模板 | 把document.xml抽象为模板,通过FreeMarker渲染 | 在XML层面操作,适合批量化、逻辑分支复杂 | 需要懂XML结构,模板制作门槛高 | 对模板逻辑有强需求的团队 |
| 商业文档组件 | 完整API,支持doc/docx/PDF互转 | 功能全面,兼容性好 | 收费,授权成本高 | 对格式保真要求极高、预算充足 |
| 本机Office COM调用 | 调用Windows上的Office程序生成 | 效果最逼真 | 依赖Windows环境和Office安装,并发差 | 内网Windows服务器、少量生成 |
2.4 我的选型基本判断
如果你问我个人怎么选,我会直接说:新项目一律用docx模板,模板引擎优先上poi-tl;如果团队里有人对XML很熟,且模板需要复杂条件判断,再考虑FreeMarker方案;只有遇到老系统死守doc、且无法改造时,才去研究转换或商业组件。把大部分精力放在“数据模型设计”和“模板规范”上,比纠结某一个库的API更有价值。
3. 用poi-tl快速落地的完整过程
poi-tl是我用下来最顺手的一个开源模板引擎,它在POI XWPF之上做了一层封装,让我可以用{{name}}这样的占位符直接在Word模板里指定填充位置,而不必关心底层XML操作。整条使用链路非常短:准备模板、准备数据、编译渲染、输出文件。
3.1 poi-tl到底解决了什么问题
用过原生POI的人都有体会,最麻烦的不是创建文档,而是“在已有模板里做局部改动”。原生方式改一个段落可能要遍历所有Run,改一个表格可能要逐单元格递归,遇到Run被拆分的情况更是欲哭无泪。poi-tl把这一层封装成模板语法,底层虽然还是POI,但对使用者来说只需要关心数据怎么组织。社区维护也比较活跃,遇到问题基本都有现成的示例。
3.2 依赖引入与最小示例
Maven项目里引入依赖:
<dependency> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.2</version> </dependency>依赖会带上POI相关组件,注意和项目里已有的POI版本保持一致,避免冲突。我遇到过因为旧项目锁定了POI 4.x,而poi-tl新版依赖POI 5.x,导致启动时一堆类冲突的情况。解决办法是统一升级POI版本,或者使用与当前POI版本匹配的旧版poi-tl。
一个最小可运行的填充例子:
import com.deepoove.poi.XWPFTemplate; import java.util.HashMap; import java.util.Map; public class WordDemo { public static void main(String[] args) throws Exception { Map<String, Object> data = new HashMap<>(); data.put("customerName", "某某信息技术有限公司"); data.put("contractNo", "HT-2024-0001"); data.put("amount", "128000.00"); data.put("signDate", "2024-06-18"); XWPFTemplate template = XWPFTemplate.compile("contract_template.docx").render(data); template.writeToFile("contract_output.docx"); template.close(); } }这段代码做的事情很简单:加载模板,往{{customerName}}等占位符里填充数据,然后输出新文件。第一次跑成功的时候,说实话我心里踏实了不少,因为它保留了模板里的红头、正文格式和表格样式,整篇文档的观感跟原始模板几乎没有区别。
3.3 模板中几个常用的占位符规则
让模板配合引擎,需要在Word模板上做一点小规范。我用得最多的占位符是下面这几种:
- 普通文本:
{{name}},直接替换成字符串或数字。 - 图片:
{{@logo}},在数据中传入图片对象。 - 列表循环:
{{?items}} ... {{/items}},中间的内容会按列表元素重复渲染。 - 条件判断:
{{?sex}}男{{/sex}}{{:sex}}女{{/sex}},根据布尔值或对象是否为空决定显示哪段。 - 单元格内循环:
{{items}}放在表格的单元格里,会自动根据列表复制行。
模板里的占位符最好手工输入一遍之后,再用Word的“查找替换”功能统一处理一次,避免一个占位符被拆成多个Run导致渲染失败。这个坑我在后面章节会详细说。
3.4 表格行循环与图片插入的代码写法
假设模板里有一张费用明细表,表头是“序号、项目、金额”,需要根据数据动态加行。模板中把这一行写成:
{{?items}} {{index}} {{projectName}} {{amount}} {{/items}}代码里只需要准备一个List,每个元素是Map:
List<Map<String, Object>> items = new ArrayList<>(); Map<String, Object> item1 = new HashMap<>(); item1.put("index", 1); item1.put("projectName", "软件开发费"); item1.put("amount", "60000.00"); items.add(item1); Map<String, Object> item2 = new HashMap<>(); item2.put("index", 2); item2.put("projectName", "设备采购费"); item2.put("amount", "68000.00"); items.add(item2); Map<String, Object> data = new HashMap<>(); data.put("items", items);图片插入稍微特殊一点。模板里放一个{{@seal}}占位符,代码里构建图片数据:
import com.deepoove.poi.data.Pictures; FileInputStream sealStream = new FileInputStream("seal.png"); data.put("seal", Pictures.ofStream(sealStream).size(120, 120).create());Pictures.ofStream支持文件流,size方法控制宽高,单位是像素,渲染时会自动换算成EMU。我做合同生成时通常会把印章图片先处理成透明背景PNG,尺寸尽量控制在200像素以内,这样既能保证清晰度,又能避免把整个docx撑得特别大。
3.5 输出后的验证要点
生成文件之后不要只看“能打开”就算完,我会按这个顺序检查:
- 用Word打开,确认没有“文件损坏”提示。
- 对比原始模板,检查页眉页脚、字体、行距是否保持一致。
- 表格循环后,确认合并单元格没有错位,表格边框完整。
- 图片位置、大小是否符合预期,尤其是印章类图片是否被拉伸。
- 把文件解压,检查document.xml里有没有非法的控制字符。
这套验证习惯帮我提前发现了不少问题,也避免把质量不过关的文件直接发给业务方。
4. 被迫使用原生POI时的“硬核”做法
虽然模板引擎很省事,但有些项目因为历史原因锁死了技术栈,或者模板本身的复杂逻辑超过了模板引擎支持的范围,这时还是要回到原生POI。这一节我讲一种能落地、且能应付大部分模板替换需求的做法。
4.1 原生POI的边界
先明确一点:原生POI做模板填充,本质是对已有docx进行“外科手术式”修改。它不像从零画文档那样自由,但也不是完全不能复用模板样式。关键思路是:先读入模板,找到目标段落或表格,再做局部替换。这样原始模板的样式、页面设置、页眉页脚都能自然保留。
4.2 文本替换:解决Run被拆散的常见问题
在打开的docx里搜索文本时,最让人头疼的就是一个占位符被Word拆成多个Run。主要原因是Word在编辑时会把同一段文字拆成多个连续片段,每个片段对应一个Run。例如{{name}}可能被拆成{{、name、}}三个Run,如果对每个Run单独执行setText,替换结果会乱七八糟。
我的做法是将段落内所有Run的文字拼起来,在拼接结果里寻找占位符,找到后用一个Run承载替换后的全部文本,并清空其他Run的文本:
import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; public void replaceInParagraph(XWPFParagraph paragraph, String placeholder, String value) { StringBuilder builder = new StringBuilder(); for (XWPFRun run : paragraph.getRuns()) { builder.append(run.text()); } String fullText = builder.toString(); if (!fullText.contains(placeholder)) { return; } String replaced = fullText.replace(placeholder, value); List<XWPFRun> runs = paragraph.getRuns(); if (runs.size() > 0) { runs.get(0).setText(replaced, 0); for (int i = 1; i < runs.size(); i++) { runs.get(i).setText("", 0); } } }要注意,setText(replaced, 0)中的第二个参数表示设置前是否先清空Runs集合里已有的文本对象。这里传入0是因为我们已经手动清空其他Run,不需要额外清空第一个Run。
这种做法的优点是不动段落结构,页面分页、缩进、行距都保留。缺点是如果有多个占位符在同一段落内,且它们被交叉拆分到不同Run,一次替换仍可能损失部分样式。遇到这种情况,我通常会在Word里先把同一段文字统一格式,让占位符落在一个Run内,从源头规避。
4.3 表格与图片的原生写法
表格处理需要遍历所有行和所有单元格,再对单元格里的段落做替换:
import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.apache.poi.xwpf.usermodel.XWPFTableCell; public void replaceInTable(XWPFTable table, String placeholder, String value) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { replaceInParagraph(paragraph, placeholder, value); } } } }图片插入相对直接,找到目标段落,创建一个Run,再把图片写入该Run:
import org.apache.poi.util.Units; import org.apache.poi.xwpf.usermodel.Document; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; FileInputStream imageStream = new FileInputStream("sign.png"); XWPFRun imageRun = paragraph.createRun(); imageRun.addPicture(imageStream, Document.PICTURE_TYPE_PNG, "sign.png", Units.toEMU(100), Units.toEMU(50));4.4 原生方式的维护成本与模板引擎的差距
用原生POI做一次模板替换并不难,难的是模板里有几十个占位符、十几张图片、多段循环表格时,代码会迅速膨胀。每新增一个占位符,都要写对应的处理逻辑;每调整一个模板字段,都要同步改代码。所以我的建议是:原生POI只适合处理少量固定文本替换,或者作为模板引擎之外的补充手段。如果预计模板会反复调整,还是尽早引入模板引擎,把“数据准备”和“模板处理”彻底拆开。
5. 实测中反复出现的坑与排查链路
这一节全是实战中一点点磨出来的经验。很多问题刚出现时完全摸不着头脑,查日志也看不出所以然,最后都是通过对比模板文件、解压docx、检查XML一步步定位的。
5.1 占位符被拆成多个Run
症状:模板里的{{name}},生成的文档里只有“name”变了,{{或}}残留。
排查过程:
- 用文本编辑器打开原始模板,把
{{name}}复制出来。 - 解压docx,打开word/document.xml,搜索
{{name}}相关片段。 - 发现这个占位符的文本被分散在多个
<w:r>节点里,每个<w:r>里只有一个或多个字符。 - 确认是Run拆散问题。
解决方式:
- 在Word模板里重新输入一遍占位符,输入后不要做任何局部格式调整,避免Word拆Run。
- 或者执行一次“查找替换”,把占位符替换为同样内容,再撤销,可以让Word重新合并Run。
- 实在不行,就在代码里先用第4.2节的合并替换思路处理一段文本,把占位符所在的Run先合并,再交给模板引擎渲染。
这个问题的根源是Word的编辑机制,不是模板引擎的bug。所以遇到时不用慌,先确认问题在模板端,再针对性处理。
5.2 中文乱码和字体丢失
症状:明明模板里用的是仿宋,生成之后变成了宋体,或者中文变成乱码。
原因主要有两类。一类是文档本身的字体声明不完整,只设置了w:ascii,没有设置w:eastAsia,Word对中文使用默认字体。另一类是代码里新创建的Run没有复制原始Run的字体属性,导致中文字体回退。
解决建议:
- 模板里的中文正文,尽量用Word内置样式管理,不要用局部格式强刷。
- 如果替换后字体不对,在代码里显式设置Run的字体名和东亚字体:
import org.apache.poi.xwpf.usermodel.XWPFRun; XWPFRun run = paragraph.getRuns().get(0); run.setFontFamily("仿宋"); run.setFontFamily("仿宋", XWPFRun.FontCharRange.eastAsia);FontCharRange.eastAsia这个参数就是专门用来设置中文字体的。很多人只调了setFontFamily("仿宋")不生效,就是漏了eastAsia。
乱码问题通常不是引擎造成的,而是模板文件本身编码损坏,或者放入的数据里有非法控制字符。我对写入字段做过一道清洗,把0x00到0x1F这些控制字符过滤掉,乱码问题立刻少了很多。
5.3 填充后样式错乱、边框消失、合并单元格失效
症状:表格行循环之后,表头边框变细,或者合并单元格错位,甚至整行消失。
排查思路:
- 确认循环区域是否包含了合并单元格。模板引擎在复制整行时,对垂直合并单元格的支持一般没问题,但水平合并(跨列合并)非常容易错位。
- 检查原始表格是否使用了“空行”占位。很多情况下业务方在表格里做了一个占位空行,这一行的单元格本身是合并过的,循环时会把合并逻辑复制出问题。
- 尽量避免让“循环行”跨越多个合并列,把需要合并展示的单元格放在表头区域,不参与循环。
如果确实需要每一行都有合并效果,建议把每一行都做成一个独立的表格块,用段落级的循环代替表格行循环,这样能绕开单元格合并带来的不稳定性。
5.4 大文档内存溢出与并发OOM
症状:单个模板要填充几百行表格、插入几十张图片,或者系统同时并发生成多个文档时,出现OutOfMemoryError。
原因:POI处理Word时会在内存中构建完整的文档树,不存在像Excel SXSSF那样的流式写模式。数据量一大,内存自然吃紧。
实测有效的处理手段:
- 控制并发数。我用一个固定线程池,限制同时生成的文档数,比如堆内存2GB时最多同时跑4个生成任务。
- 图片先压缩再插入。把图片控制在合理分辨率,避免直接插入几MB的原图。
- 大表格拆分。如果表格可能上千行,不要在一个模板里循环完,而是先生成多个小块文档,再用文档合并方式组织,或者拆成多个文件。
- 及时关闭模板对象和输出流。
template.close()、doc.close()一定要释放资源,不然泄露会积累成OOM。
5.5 Word打开提示“文件损坏,是否尝试恢复”
这种问题最让人头大,因为它往往不是必现的,有时连续生成几十份才出现一份损坏。
我排查过的原因主要有:
- 模板里包含修订、批注、书签,模板引擎处理时写入了非法XML。
- 生成过程中往Run里写入的字符串包含特殊字符,比如
&、<、>没有正确转义。 - 插入的图片输入流没有关闭,导致压缩包写入异常。
- 模板本身带有宏,另存为docx时残留了不该有的部件。
处理办法:
- 模板先执行Word的“审阅-接受所有修订”、“删除所有批注”。
- 模板统一另存为docx,不使用启用宏的docm。
- 代码里对文本做转义处理,过滤非法XML字符。
- 生成后用解压工具检查
[Content_Types].xml是否完整,缺少条目通常就是损坏点。
还有一个容易被忽略的细节:如果目标文件后缀是.doc,但实际内容却是docx,哪怕通过强制改名可以让Word打开,也容易出现“文件损坏”提示。正确做法是明确输出docx,后续如需doc格式,再通过转换工具统一转换,而不是直接改后缀。
6. 后续扩展:批量生成、模板版本管理与签名集成
当单份文档生成跑通之后,真正考验人的是把它放入生产环境,面对批量任务、模板更新和更多应用场景。这一节聊几个我项目里实际落地的扩展点。
6.1 批量生成的性能设计
批量生成千万不要循环里一个个同步生成。我的做法是准备任务表,把每份文档的生成参数放进队列,用线程池并发处理,每生成一份就把文件写到临时目录,全部完成后打包下载。这样页面响应快,也不会因为某个数据异常导致整体任务失败。
任务表里至少要有这些字段:任务ID、模板ID、数据JSON、生成状态、错误信息、生成时间。出问题时可以直接定位到哪条数据导致模板渲染失败,避免整批回滚。
6.2 模板管理与模板版本控制
模板文件最后一定要纳入版本管理,不要只放在开发人员电脑里。我见过业务方改了一版模板,结果研发不知道,生成的文档全用了老模板,最后对不上账。
我的建议是:
- 模板文件按ID存储,上传时记录版本号。
- 业务的每次模板调整,生成新版本,不覆盖旧版本。
- 生成任务记录使用的模板版本号,方便追溯某份文档是基于哪个版本生成的。
- 正式环境模板变更后,用同一份测试数据跑一遍“模板预览”,确认版式无异常再开放线上使用。
这套机制看起来简单,但能避掉“模板被覆盖”、“版本对不上”这类特别低级却又频繁发生的坑。
6.3 电子签名与合同章的对接思路
很多文档生成的最终目的不是给自己看,而是要盖章、签字、归档。Word本身可以放印章图片,但那只是视觉上的图片,不具备法律效力的防篡改能力。真正需要电子签章的地方,我通常的做法是:
- 先生成docx文件,供用户预览和二次编辑。
- 确认内容无误后,用转换服务把docx转成PDF。
- 在PDF层对接电子签章服务,完成签名和防篡改保护。
- 原docx保留为草稿,最终存档和展示统一使用已签名的PDF。
这样既保留了Word模板的编辑灵活性,又满足了正式文件的合规要求。转换这一步可以用开源的LibreOffice无头模式,也可以调用在线转换服务,具体看你对转换保真度的要求。
6.4 什么时候该考虑商业组件或服务化
开源方案并不是万能的。遇到以下情况,我会认真评估商业文档组件:
- 模板格式极其复杂,比如大量使用域代码、目录、脚注、复杂分节。
- 必须原生支持老doc格式且不能转换。
- 对转换质量要求极高,尤其是Word转PDF时页面错乱不可接受。
- 团队人力有限,不想维护模板引擎和兼容性问题的成本。
商业组件在API完整度、稳定性、技术支持上确实有优势,代价是授权费用。对甲方项目来说,如果合同里有“生成的Word必须与模板完全一致”这种条款,商业组件反而可能是性价比更高的选择。开源方案不是不能用,而是要有预期管理,把可能出现的兼容性问题提前和业务方对齐。
最后分享一个我做模板生成项目时的个人习惯:每接到一个模板,先手工填一次数据,不写代码、只做一段“跑通验证”,确认占位符位置、表格结构都能正常渲染,再进入开发。这个习惯帮我拦下了至少一半的返工需求。文档生成这类需求,真正的难点从来不在技术库本身,而在于你对模板规范、数据模型和批量流程的整体把控,把地基打好,后面怎么盖楼都不会偏。