接手这类需求的人应该都懂:业务部门拿过来一份三页的Word样例,上面画好了表格、图表、红头标题,然后轻描淡写一句“照着这个格式,把系统里的数据导出来一份”。用Apache POI从零开始画段落、调样式、拼表格,代码量能写到让人怀疑人生。后来我全面切到POI-TL,模板驱动、占位符渲染,再配合一套稳定的图表处理方案,整个导出功能的开发效率提升了一个量级。
这篇博客就完整记录我最近做的一个“2026年智能网联汽车人才需求分析报告”导出项目,覆盖Word模板动态数据填充、表格行循环、列表遍历、图片盖章和动态图表落地,重点讲清楚POI-TL的工作原理、模板设计要点,以及那些官方文档里不会明说的坑。如果你正在做类似的企业报表导出功能,这篇可以直接当参考手册用。
1. 技术选型:POI-TL为什么值得放进备选清单
1.1 同样是导出Word,常见方案差在哪
先说说我对比过的几条技术路线。很多人第一反应是直接用Apache POI的XWPFDocument写代码,我早期也这么干过:在Java代码里new一个Paragraph,new一个Table,再new一个Run,一顿操作猛如虎,最后屏幕上几百行代码只画出来一个表格。这种做法的致命问题是样式和结构全部写在代码里,业务方只要调整一次行高、列宽、间距,开发就得从头改代码。
第二类是Freemarker加Word XML模板。思路是把docx解压出来,把document.xml当模板文件,用Freemarker语法替换变量。这种方式对纯文本场景可行,但遇到表格循环、图片插入、图表这类复杂结构时,XML的命名空间和节点嵌套会让你痛不欲生,而且模板文件里全是尖括号,业务方根本没法维护。
POI-TL走的是另一条路线:模板就是干干净净的docx文件,占位符直接写在Word里,形如{{title}},循环用{{?items}}到{{/items}}包裹,图片用{{@image}}标记。程序启动时把模板文件加载进来,POI-TL扫描Word中所有文本节点,识别出占位符后调用对应的渲染策略,把数据替换进去。等于说,你维护的是那份“人话模板”,而不是那堆代码逻辑。
我做个选型对照表,大家可以根据自己团队的实际情况判断:
| 方案 | 模板可维护性 | 表格/列表支持 | 图表支持 | 学习成本 | 适用场景 |
|---|---|---|---|---|---|
| POI手写代码 | 差 | 一般 | 差 | 高 | 一次性导出、结构简单 |
| Freemarker+XML | 差 | 中等 | 差 | 高 | 纯文本批量导出 |
| POI-TL | 好 | 好 | 需配合图片或Chart XML方案 | 低 | 动态模板、复杂报表 |
| docx4j | 中等 | 中等 | 中等 | 高 | 需要深度控制docx结构 |
| Aspose.Words(商业) | 好 | 好 | 好 | 低 | 预算充足、追求省事 |
最终我选POI-TL,最核心的考量是模板可维护性。业务方以后想调表头、改图片位置、增删一行说明文字,直接改docx模板就行,开发不用跟着返工。这种“模板归模板、代码归代码”的分离,在项目迭代时价值非常大。
1.2 模板占位符和渲染策略的底层逻辑
POI-TL的底层并不神秘。docx文件本质上是一个zip压缩包,Word正文存在word/document.xml里,里面的文本内容被拆成若干个<w:r>(Run)节点。POI-TL加载完模板后,会遍历document.xml中所有的Run节点,把文本内容解析成“普通文本”和“模板指令”两类。普通文本原样保留,模板指令则交给注册好的RenderPolicy处理。
一个很关键的底层细节是:Word在保存文件时,可能把一个词拆到多个Run里,比如{{name}}可能被拆成{{na、me}}两个部分。POI-TL内部有文本合并机制,它会智能地把相邻Run里的片段拼起来再识别占位符,但前提是这些Run没有被表格单元格、段落等节点完全隔断。所以模板制作时有一个铁律:占位符必须手动输入,不要从别处复制粘贴进去,更不能在占位符中间插入光标做格式调整,否则容易造成Run拆分,渲染时识别不到。
占位符的语法体系我用得最多的是这几类:
{{title}}:普通文本变量,渲染时直接替换为字符串。{{@imageLogo}}:图片变量,渲染时把指定图片插入到占位符位置。{{?items}} ... {{/items}}:区域块循环,中间可以嵌套段落、表格等任意内容。- 表格行循环:在表格行的第一个单元格写
{{?rows}},同一行其它单元格写{{col1}}这类字段,在这一行之后的一行里写{{/rows}},配合LoopRowTableRenderPolicy实现按数据行数重复这一行。
理解了这套机制,后面写代码就不会觉得它是黑魔法。本质上就是“模板引擎 + 策略模式”,你看规则复杂,用起来其实非常直白。
1.3 关键决策:先定图表方案再动手
真正让我在这个项目里多花时间的是图表部分。POI-TL本身默认支持文本、图片、列表、表格这些元素,但它不支持动态创建Word原生图表。也就是说,你不能在模板里写一个{{chartData}},指望POI-TL自动生成一个柱状图出来。它没这个能力。
业务方要的又确实是一份带图表的报告,那怎么办?我在方案评审阶段梳理出两条路线:
方案A:服务端把图表渲染成图片,通过POI-TL的图片占位符插入文档。后端用JFreeChart、ECharts转图片、或Java2D画图都行,生成PNG后再填充到Word里。优点是实现稳定、跨平台、WPS和微软Office都能正常显示;缺点是图表在Word里是一张图片,双击不能编辑数据。
方案B:模板里预先插入Word原生图表,程序跑完后不去动图表对象本身,而是去修改图表背后的chart XML数据缓存,让Word打开时读到新数据。优点是图表可编辑,双击能修改图表样式和数据源;缺点是操作复杂度高,模板制作麻烦,而且稍有不慎就会导致文档损坏。
在这个项目里我最终选了“方案A为主、方案B作为进阶”的组合。用户双击编辑图表的需求不强烈,核心诉求是“在Word报表里看到最新数据”,那用图片方案已经能完美满足。方案B可以作为技术储备,等业务方哪天真要求可编辑图表时再上。具体怎么做,后面第2节和第4节我都会展开写。
2. 先定图表方案:动态Word图表的两条可行性路线
2.1 方案A:服务端渲染图表为图片,插入文档
方案A的思路非常朴素:既然Word图表难动态生成,那就在代码里把数据画成图,再把图作为普通图片塞进Word。这种方式对POI-TL来说只是多了一步图片渲染,模板设计时留一个{{@chartSalary}}这样的占位符就行。
生成图表的工具我用的是JFreeChart,老牌Java图表库,稳定性没得说。流行的Electron版ECharts也可以用,但为了不引入Node和浏览器环境,Java后端项目里我更推荐JFreeChart。它虽然API偏老,画出来的图表放在Word报告里完全够用,而且对中文支持可以通过注册字体解决。
关键点在于:图表图片的尺寸和质量要提前定好。Word模板里图片占位符的显示尺寸由模板决定,代码里使用的图表原始像素大小会影响清晰度。我一般把图表的原生宽高设置为Word显示宽度的2倍,比如Word里显示宽度是14厘米、约合宽394像素,那我渲染的PNG就输出788像素宽,这样高清屏下也不会糊。
还要注意图表颜色和整体风格的统一。Word文档通常有企业VI色调,JFreeChart默认的样式偏学术风,颜色饱和度挺高的,和正式报告放一起会有些违和。我在项目里封装了一套固定配色:主色用深蓝、辅助色用灰色系、强调色用橙色,尽量让图表风格与模板中已有的标题颜色、表格表头颜色保持一致。
2.2 方案B:模板预置原生图表,更新chart XML数据
方案B要动的是docx内部的chart XML文件。docx解压后能看到word/charts/chart1.xml这样的文件,里面记录了图表类型、标题、坐标轴、每个系列的数据缓存。Word打开文档时,并不要求必须连一个外部Excel,数据源就是chart XML里缓存的这些值,所以我们只要修改缓存值,Word就会显示新数据。
做法是先准备一份模板docx,里面通过Word的“插入图表”功能放一个柱状图,图表里写几行占位数据。模板做完后,用POI的XWPFChart把chart1.xml读出来,定位到CTChart对象,找到CTBarChart(柱状图)或CTPieChart(饼图)等对应的节点,逐层修改cat(类目轴)和val(数值轴)下的缓存数据,重新写出文档。
这个方案的坑在于docx的图表XML结构层级非常深。一个柱状图里至少嵌套了CTChart > CTPlotArea > CTBarChart > CTBarSer > CTCatAx/CTValAx,而每个series里又有CTStrRef/CTStrData和CTNumRef/CTNumData,改动任何一个结构节点都可能导致整个图表损坏,Word打开直接提示文件无法识别。所以方案B对POI对象模型要有一定熟悉度,迭代测试时最好每次都在Word里实际打开验证。
我给出代码前要先说明,方案B并非POI-TL的能力,而是POI操作docx底层结构的能力。POI-TL只是把主体文档渲染好了,图表这块需要另外写一段代码在保存前处理。这种“POI-TL + POI底层API”组合使用的方式,在企业报表项目里其实很常见。
2.3 我的选择与适用边界
这两个方案不是互斥关系,决策表可以这么列:
| 决策维度 | 方案A:图表图片化 | 方案B:Chart XML动态更新 |
|---|---|---|
| 开发成本 | 较低,代码量小 | 较高,需要理解chart XML结构 |
| 可编辑性 | 不可编辑 | 双击可编辑,可改数据 |
| 兼容性 | WPS/Office都可靠 | 某些精简版Office可能出现异常 |
| 模板制作 | 留图片占位符即可 | 必须预置原生图表模板 |
| 图表类型替换 | 换图就换代码生成逻辑 | 模板里换图表类型即可 |
| 适合场景 | 报表、月报、自动化导出 | 深度交互文档、自定义图表频繁变动 |
我这次项目里,业务方核心诉求是“导出给领导看”,领导不关心能不能编辑图表,只看数据和分布清楚不清楚。所以我直接走方案A,把柱状图和饼图都用JFreeChart画好再插入。方案B我额外用一个测试文档跑通了,纯粹是为了以后需求变化做准备。
如果你预算充足,也可以直接上商业文档生成库,图表支持确实好,但公司项目不一定都允许引入商业组件。开源的POI-TL+自绘图表方案,已经把性价比拉到很高了。
3. 模板设计与制作:占位符排布决定了代码复杂度
3.1 需求拆解:要导出一份什么样的人才报表
项目背景是给一家汽车行业研究机构做数据报表导出功能,他们需要定期生成《2026年智能网联汽车人才需求分析报告》。注意,这里的数据是演示用的示例数据,实际系统会从数据库中实时读取,但字段结构完全按照这份报告的格式来。
我拿到的Word样例大概是这样:封面页有报告标题、机构LOGO;第二页开始是“人才缺口概况”,包含一张表格,列为岗位名称、人才缺口规模(万人)、平均月薪(千元)、紧缺指数;表格下方是一个柱状图,展示各岗位缺口规模对比;再往下是一个饼图,展示不同岗位方向的薪资分布比例。
需求拆解到最后就是三部分:
- 表格:多行记录,模板里放一行,程序循环生成多行。
- 柱状图:横轴是岗位名称,纵轴是人才缺口规模。
- 饼图:展示各岗位方向的薪资占比。
封面上的LOGO用图片占位符处理,报告标题、日期等字段用普通变量。
把需求落到模板这个过程,我建议画一张“字段映射表”,把业务字段和模板占位符一一对应。这张表我每次做项目都会整理一份,后面查问题、交接同事都靠它:
| 业务描述 | 模板位置 | 占位符 | 数据类型 |
|---|---|---|---|
| 报告标题 | 封面首行 | {{reportTitle}} | 字符串 |
| 报告生成日期 | 封面尾部 | {{reportDate}} | 字符串 |
| 机构LOGO | 封面右上角 | {{@logo}} | 图片 |
| 表格数据区 | 第二页表格 | {{?rows}}...{{/rows}} | List嵌套对象 |
| 岗位缺口柱状图 | 表格下方 | {{@chartShortage}} | 图片 |
| 薪资分布饼图 | 下一页 | {{@chartSalary}} | 图片 |
有了这张表,模板怎么做、代码怎么写,思路立刻就清晰了。
3.2 模板里的三类占位符怎么摆
占位符的摆放位置直接影响代码逻辑,我强烈建议第一次做的时候就在Word里把分节符、换行、表格结构都设计到位,不要指望代码去补救模板缺陷。
先说普通变量。封面标题的地方,光标定位到位,直接输入{{reportTitle}},然后设置好字体、字号、居中样式。POI-TL渲染时只会替换占位符文本本身,不会改变它的字体和段落样式,所以模板里字体选什么,最终导出的文档就是什么字体。
再说图片占位符。在LOGO要出现的位置输入{{@logo}},然后把占位符文字的字体大小设置成一个合适的行内高度,因为图片会按模板中字符位置插入,如果字号太小,图片显示区域会被压缩。图片的实际大小、等比缩放,POI-TL里可以通过PictureRenderData的宽高参数控制,但模板里最好预留出一个“占位区域”,留的位置不对,后面就算图片插进去,版式也会别扭。
最关键的是表格行循环。这是POI-TL最常用的能力,做法是:在表格目标行左侧第一列输入{{?rows}},同一行需要填充数据的其它列分别输入{{name}}、{{gap}}、{{salary}}、{{index}},然后在表格的下一行(注意是表格里换行,不是文档换行)第一列输入{{/rows}}。POI-TL配合LoopRowTableRenderPolicy,会把这两行之间的区域识别为一个“循环行模板”,渲染时参照数据集合中的每个对象重复这一行内容。
这个方案有一个常见坑:循环行的样式和边框会继承模板行,但如果模板里{{/rows}}所在的行本身是空行,渲染后表格末尾可能会多出一条空白线。解决办法是让{{/rows}}借用上一行的表格格式,或者渲染完成后删除这个结束标记行,最好在模板里就把结束行和循环行做成一模一样的格式,这样重复出来的行样式才统一。
3.3 图表图片占位与图表数据区的设计
既然我走图表图片化方案,模板这部分就比较简单了:在柱状图该出现的位置插入一个段落,留一行文字{{@chartShortage}},设置好段落的居中样式和预留空行,图片渲染后就会出现在这里。
但有一点要提醒:Word文档里图片是“锚定”在段落上的,如果你在模板里让图片占位符紧跟着表格底部,没有任何空行,图片可能会紧贴表格线,排版很难看。我一般在图片占位符前后各留一个空段落,渲染时给图片加上居中对齐,再通过PictureRenderData控制宽度不超过版芯(一般A4纸左右页边距下版芯宽度在14到16厘米之间),这样导出的文档才像人工排过版。
如果你后续打算走方案B,模板设计就要复杂一些:先用Word的“插入图表”功能插入一个占位柱状图,图表类型选好,数据区域随便填三个演示数据点。然后不要关闭Word,右键图表选择“编辑数据”,把数据区的行列结构改成实际需要的结构。比如柱状图横轴有6个岗位名称,那数据区提前设置成6行,数值列也填6个示例值。模板保存后,交给代码去更新这个chart1.xml里面的缓存值。这样做的目的是提前约定好“数据维度”,代码更新时会减少对维度的判断。
3.4 模板样式检查清单
模板做出来后,开发之前先自查一遍,能省掉一大堆调试时间:
- 占位符确认没有误被Word自动拼写检查改掉,比如
{{reportTitle}}里的花括号不会被替换成中文花括号。 - 占位符前后不要有多余空格,否则渲染后会有尴尬的空白。
- 表格循环行和结束行格式一致,表头行格式单独设置,避免重复行带上“表头主题”。
- 图片占位符所在段落不要设置“首行缩进”,否则图片会跟着缩进。
- 模板文件另存为
.docx格式,而不是.doc,POI-TL不支持老格式。 - 模板里不要包含宏、书签、域代码等特殊内容,这些在POI处理时可能引发意外错误。
- 如果模板里字体用到特殊字体,确认目标环境里有,比如热词里提到的“word黑体字体下载”,实际是系统缺少字体导致打开文档字体异常,不是程序问题。
这份检查清单是我踩过几次坑后总结出来的,照着过一遍再开始写代码,后面调试轻松很多。
4. 编码实现:从Maven依赖到渲染管线
4.1 依赖引入与版本兼容
先看Maven依赖。POI-TL版本和POI大版本必须严格对应,我项目里用的是POI-TL 1.12.1,对应Apache POI 5.x系列。如果你用了POI-TL 1.10.x,配的是POI 5.1.0,问题不大;但如果跳版本,比如POI-TL 1.12配POI 4.x,启动时大概率会报类找不到异常,因为POI-TL内部用到了POI 5.x的新API。
<dependency> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.1</version> </dependency>POI-TL会传递依赖POI相关库,如果你项目里已经有其它POI组件,要注意统一版本,避免版本冲突。我在pom里通常再显式指定一遍:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml-schemas</artifactId> <version>4.1.2</version> </dependency>等等,poi-ooxml-schemas这个版本在POI 5.x之后已经不太需要单独引入了,因为POI 5开始把schema打成poi-ooxml-full模块,而且默认传递依赖里通常能解决。如果你用POI-TL 1.12.1加POI 5.2.3,只引入poi-tl和poi-ooxml就够了,带schema反而可能出现旧类和新的冲突。这块主要是提醒大家注意版本,具体依赖以实际导入结果为准,出现冲突就检查一下传递依赖树。
图表图片化方案需要额外引入JFreeChart:
<dependency> <groupId>org.jfree</groupId> <artifactId>jfreechart</artifactId> <version>1.5.4</version> </dependency>JFreeChart的中文显示需要注册字体,后面会写到。
4.2 核心渲染工具类封装
POI-TL的编码风格很简洁,核心流程三步:加载模板、绑定数据、输出文件。我建议把这些封装成一个工具类,避免每个接口都重复写一遍配置。
import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.config.Configure; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Map; public class WordExportUtils { private static final Configure CONFIGURE = Configure.builder() .build(); private WordExportUtils() { } public static void render(InputStream templateStream, OutputStream outputStream, Map<String, Object> dataMap) throws IOException { XWPFTemplate template = XWPFTemplate.compile(templateStream, CONFIGURE); template.render(dataMap); template.writeAndClose(outputStream); } }这个工具类看着简单,但里面有几个隐藏细节。writeAndClose会在写出后关闭模板资源,所以调用方不需要再手动关模板,但outputStream是否关闭由调用方控制,防止把上层response的输出流误关掉。如果你的系统里渲染完还要继续用这个OutputStream,比如文件名编码处理后再写入,那就别关。
Configure默认配置就能搞定大部分场景,但如果你在模板里使用图解或特殊标签前缀,需要自定义配置。比如默认标签语法是{{和}},你要是模板里大量出现JSON字符串或JS模板,可能与默认标签冲突,这时可以改成其它符号,但一般项目用不到,保持默认就好。
4.3 表格行循环与列表遍历的实现
重点是表格循环。先定义一个数据结构,对应表格每行的字段:
public class PositionGap { private String name; // 岗位名称 private String gap; // 人才缺口规模(万人) private String salary; // 平均月薪(千元) private String index; // 紧缺指数 // getter/setter 省略 }然后在渲染逻辑里,把表格数据封装为TableRenderData或者直接用LoopRowTableRenderPolicy配合List。POI-TL官方推荐的做法是:
import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.policy.LoopRowTableRenderPolicy; import com.deepoove.poi.data.PictureRenderData; import com.deepoove.poi.config.Configure; import java.io.FileInputStream; import java.io.FileOutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class ReportService { public void generateReport() throws Exception { // 1. 准备表格数据 List<PositionGap> rows = new ArrayList<>(); rows.add(new PositionGap("智能驾驶算法工程师", "12.6", "28.5", "0.92")); rows.add(new PositionGap("智能座舱开发工程师", "9.4", "22.8", "0.85")); rows.add(new PositionGap("车联网通信工程师", "7.8", "20.3", "0.79")); rows.add(new PositionGap("三电系统工程师", "6.5", "18.6", "0.74")); // 2. 配置LoopRowTableRenderPolicy LoopRowTableRenderPolicy tablePolicy = new LoopRowTableRenderPolicy(); Configure config = Configure.builder() .bind("rows", tablePolicy) .build(); // 3. 组装数据 Map<String, Object> data = new HashMap<>(); data.put("reportTitle", "2026年智能网联汽车人才需求分析报告"); data.put("reportDate", "2026-03-15"); data.put("rows", rows); // 4. 渲染输出 try (FileInputStream in = new FileInputStream("template/report_template.docx"); FileOutputStream out = new FileOutputStream("output/report_result.docx")) { XWPFTemplate template = XWPFTemplate.compile(in, config); template.render(data); template.writeAndClose(out); } } }这段代码里最关键的是第2步:bind("rows", tablePolicy)。POI-TL默认模板配置不认识rows这个标签,如果不绑定,它会把{{?rows}}当普通文本输出。绑定之后,遇到{{?rows}}就会调用LoopRowTableRenderPolicy去解析这一行的重复逻辑。
除了表格行循环,列表遍历也是个高频需求。比如报告末尾要列“建议措施”,是一个带编号或符号的列表。最简单的做法是把整个列表区域用{{?measures}}到{{/measures}}包起来,里面每一行是一个普通段落占位符{{this}},数据集合是List 。
data.put("measures", List.of( "加强高校智能网联相关专业建设,扩大人才供给。", "推动企业建立内部转岗培训机制,盘活存量人才。", "完善跨区域人才引进与安居政策。" ));模板里这样摆:
{{?measures}} {{this}} {{/measures}}POI-TL渲染时会把这个区域重复3次,每次把{{this}}替换成集合中的一项。这种写法适合段落式的无序列表。如果你需要自动带项目符号,模板里可以在{{this}}前面手动打一个“·”符号,因为Word的自动编号列表在模板重复块中处理起来容易出问题,我一般不用Word原生编号,直接手动加符号最省心。
4.4 图片、盖章、图表插入
图片填充用PictureRenderData。这个类接收图片路径(或输入流)、图片类型、显示宽高。比如封面LOGO:
data.put("logo", new PictureRenderData(120, 60, "template/logo.png"));注意宽高的单位是像素。120x60表示渲染时图片显示为120像素宽、60像素高。如果你不确定实际应显示多大,可以先查一下模板里图片占位符附近的字体大小和行高,一般LOGO区域控制在100到160像素宽,高度在50到80像素之间比较合理。
电子盖章场景也是图片填充,模板里放一个{{@stamp}},渲染时传入带透明背景的PNG盖章图片。这里提醒一句:盖章图片一定要是透明背景,如果有白色底色,盖在文字上会盖住底下的内容,看起来非常假。POI-TL对图片不校验透明度,纯看你传入的图片是什么样,所以图片处理在上游就做好。
图表插入利用方案A,先生成图片BufferedImage,再转成POI-TL的RenderData。生成图表的工具方法我单独封装:
import org.jfree.chart.ChartFactory; import org.jfree.chart.JFreeChart; import org.jfree.chart.axis.CategoryAxis; import org.jfree.chart.axis.CategoryLabelPositions; import org.jfree.chart.plot.CategoryPlot; import org.jfree.chart.plot.PlotOrientation; import org.jfree.chart.renderer.category.BarRenderer; import org.jfree.data.category.DefaultCategoryDataset; import java.awt.*; import java.awt.image.BufferedImage; public class ChartBuilder { static { // JFreeChart中文显示必须注册中文字体 Font font = new Font("Microsoft YaHei", Font.PLAIN, 14); // 注册到GraphicsEnvironment java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(font); } public static BufferedImage buildShortageChart(List<PositionGap> rows) { DefaultCategoryDataset dataset = new DefaultCategoryDataset(); for (PositionGap row : rows) { dataset.addValue(Double.parseDouble(row.getGap()), "人才缺口(万人)", row.getName()); } JFreeChart chart = ChartFactory.createBarChart( "各岗位人才缺口规模", "岗位名称", "缺口规模(万人)", dataset, PlotOrientation.VERTICAL, false, true, false ); // 配置中文轴字体 CategoryPlot plot = chart.getCategoryPlot(); CategoryAxis domainAxis = plot.getDomainAxis(); domainAxis.setCategoryLabelPositions(CategoryLabelPositions.UP_45); domainAxis.setLabelFont(new Font("Microsoft YaHei", Font.PLAIN, 14)); domainAxis.setTickLabelFont(new Font("Microsoft YaHei", Font.PLAIN, 12)); chart.getTitle().setFont(new Font("Microsoft YaHei", Font.BOLD, 18)); // 把JFreeChart转成BufferedImage BufferedImage image = chart.createBufferedImage(800, 450); return image; } }然后把它插入数据Map:
BufferedImage shortageImage = ChartBuilder.buildShortageChart(rows); data.put("chartShortage", new PictureRenderData(540, 300, toInputStream(shortageImage, "png")));中文问题的核心是JFreeChart里所有涉及文字的节点都要设置中文字体,否则画出来全是方块。虽然registerFont已经注册了,但JFreeChart内部有些组件(比如Legend、Title)不一定直接用注册字体,所以我在代码里显式给Title、Axis都设置了字体,这是实践出来的经验。
4.5 方案B的chart XML更新实现
虽然用户需求最终用了方案A,但方案B的代码我也在这分享出来。核心思想是用POI的XWPFChart打开原生图表,修改XML数据缓存。
import org.apache.poi.xwpf.usermodel.XWPFChart; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.openxmlformats.schemas.drawingml.x2006.chart.CTBarChart; import org.openxmlformats.schemas.drawingml.x2006.chart.CTBarSer; import org.openxmlformats.schemas.drawingml.x2006.chart.CTChart; import org.openxmlformats.schemas.drawingml.x2006.chart.CTChartSpace; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumDataSource; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumRef; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumData; import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumVal; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrDataSource; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrRef; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrData; import org.openxmlformats.schemas.drawingml.x2006.chart.CTStrVal; import java.util.List; public class ChartXmlUpdater { public static void updateFirstBarChart(XWPFDocument document, List<String> categories, List<Double> values) { List<XWPFChart> charts = document.getCharts(); if (charts.isEmpty()) { return; } XWPFChart chart = charts.get(0); CTChartSpace chartSpace = chart.getCTChartSpace(); CTChart ctChart = chartSpace.getChart(); // 假设模板里第一个图就是柱状图 if (ctChart.getBarChartList().isEmpty()) { return; } CTBarChart barChart = ctChart.getBarChartList().get(0); List<CTBarSer> seriesList = barChart.getSerList(); if (seriesList.isEmpty()) { return; } CTBarSer series = seriesList.get(0); // 更新类目(X轴) CTStrDataSource cat = series.getCat(); CTStrRef strRef = cat.getStrRef(); CTStrData strCache = strRef.getStrCache(); strCache.setPtCount(safeLong(categories.size())); // 先清空旧pt,再填充新值(这里只保留一个series演示) strCache.setPtArray((org.openxmlformats.schemas.drawingml.x2006.chart.CTStrVal[]) null); for (int i = 0; i < categories.size(); i++) { CTStrVal pt = strCache.addNewPt(); pt.setIdx(i); pt.setV(categories.get(i)); } // 更新数值(Y轴) CTNumDataSource val = series.getVal(); CTNumRef numRef = val.getNumRef(); CTNumData numCache = numRef.getNumCache(); numCache.setPtCount(safeLong(values.size())); numCache.setPtArray((org.openxmlformats.schemas.drawingml.x2006.chart.CTNumVal[]) null); for (int i = 0; i < values.size(); i++) { CTNumVal pt = numCache.addNewPt(); pt.setIdx(i); pt.setV(String.valueOf(values.get(i))); } } private static long safeLong(int size) { return size; } }注意这段代码里有一个关键操作:strCache.setPtArray(...)设置为null。因为XML Schema对象不允许重复addNewPt,如果不清空旧数据,新数据会追加在旧数据后面,图表显示就乱了。我在测试时因为这个清空操作纠结很久,后来发现直接setPtArray为null再重新add,是最干净的处理方式。
更新完chart XML之后,还要注意图表标题、坐标轴标题如果用到了引用单元格值,可能也要同步改,否则标题还是旧内容。另外,如果模板里图表绑定了外部数据区域,比如Sheet1!$A$1:$A$6,Word打开时会尝试去读取链接数据。一般动态导出场景不需要保留这种数据链接,建议模板制作时就把数据区域改成“不包含外部链接”的内部缓存模式,也就是把图表数据当作纯缓存数据处理。
方案B的代码细节比方案A多很多,生产落地前一定要做文档修复校验。我建议在代码里渲染完不算完,至少实际用Word打开一次看图表有没有报错。如果只是程序返回正常、打开文件就报“图表损坏”,说明chart XML的结构有问题。
4.6 文件输出与资源释放
文件输出阶段有几个小坑值得提一下。第一是文件名中文乱码。导出时如果文件名里有中文,HTTP响应头必须做URL编码处理:
String fileName = URLEncoder.encode("2026年智能网联汽车人才需求分析报告.docx", "UTF-8") .replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName);第二是资源释放。POI-TL的writeAndClose会关闭模板输入流和内部资源,但你的OutputStream不会自动关。在Web项目里,如果用response.getOutputStream()作为输出,那绝对不能在这里关掉response流,否则后续框架再写内容会报错。正确的做法是只调用writeAndClose,然后让Servlet容器去管理response流。
第三是渲染大文档时的内存。POI-TL会把整个docx加载进内存,如果一个模板渲染出几十页文档,堆内存会明显增长。我建议在服务里给导出任务单独配置线程池和超时,防止大报表把主业务线程拖垮。后面第6节我会展开讲工程化落地方案。
5. 常见问题与排查实录
5.1 表格列宽无法拖动
用过POI-TL的人很多都遇到过这个现象:渲染出来的Word表格,行高列宽看着正常,但用户在Word里手动拖拽列宽时,完全拖不动,或者拖完保存再打开又变回去了。
这背后的原因是POI-TL渲染时保留了模板中的表格布局属性和固定列宽值。Word表格有两种布局方式:autofit(自动调整)和fixed(固定宽度)。模板如果被Word设置成固定布局,渲染后每个单元格的宽度都是写死的<w:tcW>值,用户拖动列宽时,Word会按固定宽度重新计算,表现就是拖不动或拖了没效果。
解决办法有两个层面。模板层面:在Word里选中表格,右键“表格属性”,选项里把“自动调整尺寸”设为“根据内容调整表格”或者“根据窗口调整表格”,这样生成时表格布局就是autofit类型。代码层面:如果模板已经要做较大改动,可以在渲染后拿到XWPFTable对象,手动设置表格布局:
import org.apache.poi.xwpf.usermodel.XWPFTable; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblLayoutType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblLayoutType; public static void setTableAutofit(XWPFTable table) { CTTblPr tblPr = table.getCTTbl()!= null ? table.getCTTbl().getTblPr() : null; if (tblPr == null) { tblPr = table.getCTTbl().addNewTblPr(); } CTTblLayoutType layout = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.AUTOFIT); }不过这个代码在POI-TL场景里有点“亡羊补牢”,因为渲染完再改表格属性,可能影响到循环行里已经填充的单元格宽度。我的建议是优先在模板层解决,模板里表格自动调整,生成出来的表格用户就能正常拖拽列宽。
5.2 文档损坏:Word在试图打开文件时遇到错误
“Word在试图打开文件时遇到错误,请尝试下列方法”这段弹窗,几乎是做文档生成功能的人都会遇到的。我遇到过的原因有四种,按概率排列:模板文件本身损坏、多次渲染同一份模板导致资源未释放、Configuration配置不正确、方案B更新chart XML时破坏了文档结构。
排查方法很简单:把生成好的docx用解压工具打开,检查[Content_Types].xml、word/document.xml等关键文件是否完整。如果document.xml里出现了未闭合的标签、非法字符,那说明渲染过程写坏了。还有一个常见情况是模板流没有关闭,程序里同一份FileInputStream被多线程并发读,导致模板流提前读完,POI读到不完整的内容。
我在工具类里用XWPFTemplate.compile(InputStream, config)时,输入的InputStream必须是未读取过的完整流。如果上游从OSS、MinIO下载模板,拿到的是网络流,最好先读成byte[]再转ByteArrayInputStream,避免网络流超时或半截。
如果文档损坏问题只在图表方案B时出现,优先检查chart1.xml的<c:ptCount>与实际<c:pt>数量是否一致。ptCount比实际pt数量多或少,Word打开图表时都会报错。
5.3 占位符残留、标签被Word自动替换
模板渲染后,正文里留下了未替换的{{xxx}},这是最高频的求助问题。大部分原因是占位符对应的key没有在数据Map里提供。POI-TL默认遇到没有数据的标签会原样保留,而不是替换为空。你可以在Configure里设置ELMode或自定义策略让缺失变量渲染为空,但更稳的做法还是做一次“标签覆盖检查”,渲染前从模板中提取所有占位符,和数据Map的key做差集,提前发现漏绑定的情况。
还有个隐蔽问题:Word会自动把英文双引号转成中文弯引号,把花括号识别为某种域代码。我自己踩过“{{title}}”被Word自动更正为“{{title}}”的坑,模板里看着像全角括号,程序怎么都识别不到。制作模板时,如果发现花括号颜色和普通文字不同(Word会把它识别为域或字段),建议直接在Word的“自动更正选项”里关闭相关替换功能。
5.4 图表不刷新、数据不生效
方案B更新chart XML之后,打开Word发现图表还是模板里的旧数据,这种问题十有八九是chart XML缓存数据没有真正改到位。因为Word图表的数据源可能同时存在两个位置:一个是word/charts/chartN.xml里的缓存数据,一个是嵌入式Excel的缓存(word/embeddings/Microsoft_Excel_工作表.xlsx)。如果模板插入图表时保留了嵌入Excel数据源,Word打开后会优先尝试读取嵌入Excel里的数据,即使chart XML里的缓存改了,显示时也可能被Excel数据覆盖。
解决办法是模板制作时就断开图表与嵌入Excel的关联。插入图表后,在Word里把图表数据源区域改为“不包含外部链接”的内部数据,或者干脆在制作模板时用“粘贴为图表”而不是“插入图表”。具体操作路径因Word版本而异,核心目标是让chart XML成为唯一数据源。之后用代码更新chart XML缓存,Word就能正常显示新数据。
方案A就不存在这种问题,因为图片是静态的,打开就一定显示图片内容。这也是我在这个项目里选择方案A的重要原因。
5.5 导出的Word关闭卡顿、体积异常
热词里有“word关闭时卡顿”“word关闭很慢怎么解决”,这类问题在自动化导出的场景下也有对应版本。如果你生成的docx动辄几十MB,Word打开和关闭都会非常卡,用户体验很差。
Word文档体积大的常见原因是图片。POI-TL插入图片时如果传入的是原始截图,分辨率很高、体积很大,整个文档就会变得臃肿。解决方向有两个:一是图片压缩,在渲染前把图片重新采样,控制分辨率;二是控制图片数量,一个报告里图片占位符别堆太多。
还有一个隐蔽问题是模板里如果有一段空的、反复嵌套的表格或书签,渲染后这些结构会被放大,导致Word解析缓慢。我排查过一份卡顿文档,解压后document.xml里多了几千个空段落节点,都是从模板重复区域里带出来的。遇到这种情况,优先检查模板里循环块和结束块之间的内容是否足够“干净”,尽量只保留真正会被重复渲染的元素,不要在里面放整块的空白段落占位。
5.6 字体与中文乱码
中文乱码最常见于两个环节:一是代码处理字符串时编码不一致,比如从数据库读取中文时没有设置UTF-8,渲染后全变成问号;二是JFreeChart画图片时中文输出成方块,这个前面已经提到,需要注册并显式设置中文字体。
热词里还有“word黑体字体下载”这类搜索,说明很多人在导出文档后发现字体和模板对不上,或者目标电脑打开时字体被替换。这个问题在服务端生成文档时非常典型:服务器上未必安装业务方使用的字体。POI-TL渲染时,模板里的字体是记录在XML里的字体名称,Word打开时会在本机搜索同名字体,找不到就用替代字体。如果希望字体效果稳定,可以把字体文件做成字体子集嵌入docx,但这是一个耗时且复杂的操作。我的建议是:模板选择通用字体,比如微软雅黑、宋体、黑体,尽量避开特殊设计字体,这样在Windows环境下打开基本能保持一致。
6. 工程化落地经验:模板维护与自动化测试
6.1 模板版本管理与命名规范
项目跑起来之后,模板一定会频繁改版。我在项目里把模板按“版本号+业务场景”命名,同时放在独立的资源目录里,比如template/v1/report_template.docx。每次业务方提出模板修改,我都会复制一份新版本,不直接在原文件上改,这样出了问题能随时回滚。
模板内部也可以加一个“版本标记区”,比如在文档末尾或页脚写一行TemplateVersion: v1.0.0,程序渲染时把这个版本号记录到日志里。以后用户反馈“导出的文档格式不对”,一查日志就知道用的是哪个模板版本,定位问题快很多。
6.2 自动化校验:用代码检查占位符覆盖情况
模板改多了,容易出现一个典型事故:新模板加了字段,程序没同步更新数据Map,导致导出文档出现{{newField}}残留。为了避免这个问题,我写了一个简单的校验工具,在单元测试阶段扫描模板中的所有占位符,和当前代码里提供的数据key做比对:
import com.deepoove.poi.policy.reference.TemplateVisitor; // 伪代码,实际使用可基于POI-TL的TemplateVisitor扫描模板中的标签实现思路是用POI解析模板文档,然后通过正则匹配\{\{([^}]+)\}\}之类的模式,提取所有占位符key,再结合渲染时的数据Map做差集校验。这个测试放在CI里,模板一变,自动化测试立刻能跑出漏绑定问题,比等用户发现再反馈好太多了。
正则匹配时要过滤掉{{?xxx}}、{{/xxx}}这类循环控制指令,只校验普通变量和图片标签。如果是循环块里的子字段,也要根据循环块的类型检查内部key。
6.3 后续扩展:转PDF、批量导出、定时报表
Word导出功能稳定后,业务方自然会提下一个需求:能不能导成PDF?能不能一次导出多份?能不能每天早上定时生成推送到群里?
转PDF我推荐两种路线。如果只是预览和打印,有预算可以上商业方案;开源路线可以用LibreOffice无头模式转换,或者用POI渲染完再通过docx4j转PDF,但效果和格式保真度都有限。POI-TL不提供PDF能力,所以转PDF要单独做一层。
批量导出要关注的是并发和内存。我通常用线程池控制并发数据组装,每个任务独立加载自己的模板流,渲染完成后立即释放。模板流可以从缓存里读取,避免每个任务都去OSS拉一次文件,但要注意并发读同一InputStream的问题,最好每个线程都基于byte[]创建独立流。
定时报表本质上是把整个导出功能封装成一个可复用的Job,定时任务把数据查出来,渲染成Word,再通过企业内部的报表平台推送。这里要用到的能力其实都已经在前面几节讲完了,剩下的只是组装调度逻辑。
我在落地这类导出功能时,最大的体会是“模板先行、数据其次、代码最后”。先把模板和字段映射表定清楚,写代码反而是最快的环节。你如果正被Word动态导出折磨,不妨按这个顺序试一次:梳理需求字段、制作模板、画字段映射表、再写渲染代码,你会发现大部分坑都能在模板阶段就避开。