基于POI-TL实现动态Word模板的数据填充:散点图特殊处理方案
在使用POI-TL进行Word模板动态数据填充时,图表生成是一个常见需求。最近在项目中使用POI-TL处理散点图时遇到了一个特殊问题,经过研究后找到了解决方案,特此记录分享。
问题背景
POI-TL作为一款优秀的Java Word模板引擎,提供了丰富的图表渲染功能。然而在使用默认的多系列插件渲染散点图时,会出现一个奇怪的错误:当散点图的类别是中文时,POI-TL会把这些中文类别当作x轴数据来渲染,而实际上散点图的x轴需要的是数字索引,这就导致了图表渲染失败。报错截图:
问题分析
通过研究Word图表中散点图的示例数据,我发现:
散点图的数据格式与其他图表(如柱状图、折线图)在结构上是一致的都可以包含中文类别关键区别在于:散点图的x轴需要显示数字索引,而其他图表可以直接使用文字类别
默认插件的问题在于没有区分散点图和其他图表的这种差异,统一将类别作为x轴数据处理,当类别是中文时就会出现解析错误。
解决方案
POI-TL提供了灵活的插件扩展机制,允许我们通过实现RenderPolicy接口开发自定义插件。我的解决方案是开发一个专门处理散点图的自定义插件,重写其数据源处理逻辑。
核心思路是:分离”X轴显示标签”和”散点数值坐标”
核心改进说明
类别→索引映射:通过
方法将中文类别(如”数据分析应用”)转为数值索引(1,2,3…),作为散点图的实际X坐标。
createCategoryIndexMap
数据源分离:
散点图的X轴数据源使用数值索引(避免字符串解析问题)X轴的显示标签仍为中文类别(不影响视觉展示)
兼容原有逻辑:继承默认插件的大部分逻辑,仅修改散点图的X轴数据处理,确保与其他图表类型(柱状、折线)兼容。
代码实现
下面是自定义散点图渲染插件的完整实现:
package com.hdxm.server.sample.utils;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.data.ChartMultiSeriesRenderData;
import com.deepoove.poi.data.SeriesRenderData;
import com.deepoove.poi.exception.RenderException;
import com.deepoove.poi.policy.reference.MultiSeriesChartTemplateRenderPolicy;
import com.deepoove.poi.template.ChartTemplate;
import com.deepoove.poi.util.ReflectionUtils;
import org.apache.poi.xddf.usermodel.chart.XDDFAreaChartData;
import org.apache.poi.xddf.usermodel.chart.XDDFBarChartData;
import org.apache.poi.xddf.usermodel.chart.XDDFChart;
import org.apache.poi.xddf.usermodel.chart.XDDFChartData;
import org.apache.poi.xddf.usermodel.chart.XDDFDataSource;
import org.apache.poi.xddf.usermodel.chart.XDDFLineChartData;
import org.apache.poi.xddf.usermodel.chart.XDDFNumericalDataSource;
import org.apache.poi.xddf.usermodel.chart.XDDFScatterChartData;
import org.apache.poi.xwpf.usermodel.XWPFChart;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
* poi-tl在用默认的多系列插件渲染散点图时会报错,它会把类别当做x轴来渲染数据,
* 而实际的散点图类别是中文,但图上的x轴是数字索引,这样就会报错,因此要自定义一个插件用来渲染散点图
* @author: Hanweihu
* @date: 2025/9/12 9:53
*/
public class ScatterChartRenderPolicy extends MultiSeriesChartTemplateRenderPolicy {
private void validate(List<XDDFChartData> chartSeries, ChartMultiSeriesRenderData data) {
// 验证组合图表
if (chartSeries.size() >= 2) {
long nullCount = data.getSeriesDatas().stream().filter(d -> null == d.getComboType()).count();
if (nullCount > 0) throw new RenderException("组合图表必须设置series的comboType字段!");
}
}
private int ensureSeriesCount(XWPFChart chart, List<XDDFChartData> chartSeries) throws IllegalAccessException {
// 修复poi 4.1.1+中的seriesCount值
int totalSeriesCount = chartSeries.stream().mapToInt(XDDFChartData::getSeriesCount).sum();
Field field = ReflectionUtils.findField(XDDFChart.class, "seriesCount");
field.setAccessible(true);
field.set(chart, totalSeriesCount);
return totalSeriesCount;
}
private List<SeriesRenderData> obtainSeriesData(Class<? extends XDDFChartData> clazz,
List<SeriesRenderData> seriesDatas) {
Predicate<SeriesRenderData> predicate = data -> {
return false;
};
if (clazz.equals(XDDFBarChartData.class)) {
predicate = data -> SeriesRenderData.ComboType.BAR == data.getComboType();
} else if (clazz.equals(XDDFAreaChartData.class)) {
predicate = data -> SeriesRenderData.ComboType.AREA == data.getComboType();
} else if (clazz.equals(XDDFLineChartData.class)) {
predicate = data -> SeriesRenderData.ComboType.LINE == data.getComboType();
}
return seriesDatas.stream().filter(predicate).collect(Collectors.toList());
}
@Override
public void doRender(ChartTemplate eleTemplate, ChartMultiSeriesRenderData data, XWPFTemplate template) throws Exception {
XWPFChart chart = eleTemplate.getChart();
List<XDDFChartData> chartSeries = chart.getChartSeries();
validate(chartSeries, data);
// 为散点图准备数值索引映射(中文类别→数值索引)
Map<String, Integer> categoryToIndex = createCategoryIndexMap(data.getCategories());
int totalSeriesCount = ensureSeriesCount(chart, chartSeries);
int valueCol = 1;
List<SeriesRenderData> usedSeriesDatas = new ArrayList<>();
for (XDDFChartData chartData : chartSeries) {
int orignSize = chartData.getSeriesCount();
List<SeriesRenderData> currentSeriesData = getCurrentSeriesData(chartData, data);
usedSeriesDatas.addAll(currentSeriesData);
int currentSeriesSize = currentSeriesData.size();
// 处理 X 轴数据源:散点图用数值索引,其他图表用字符串标签
XDDFDataSource<?> categoriesData = createCategoryDataSource(chart, chartData, data, categoryToIndex);
for (int i = 0; i < currentSeriesSize; i++) {
SeriesRenderData seriesData = currentSeriesData.get(i);
// 散点图的 X 坐标使用数值索引,Y 坐标使用原始数值
Number[] xValues = chartData instanceof XDDFScatterChartData
? getScatterXValues(seriesData, data.getCategories(), categoryToIndex)
: seriesData.getValues();
XDDFNumericalDataSource<? extends Number> valuesData = createNumbericalDataSource(
chart, xValues, valueCol);
XDDFChartData.Series currentSeries = getOrCreateSeries(chartData, i, orignSize, categoriesData, valuesData);
currentSeries.setTitle(seriesData.getName(), chart.setSheetTitle(seriesData.getName(), valueCol));
valueCol++;
}
removeExtraSeries(chartData, orignSize, currentSeriesSize);
}
// 复用父类的表格和轴标题处理逻辑
updateCTTable(chart.getWorkbook().getSheetAt(0), usedSeriesDatas);
removeExtraSheetCell(chart.getWorkbook().getSheetAt(0), data.getCategories().length, totalSeriesCount, usedSeriesDatas.size());
for (XDDFChartData chartData : chartSeries) {
plot(chart, chartData);
}
setTitle(chart, data.getChartTitle());
setAxisTitle(chart, data.getxAxisTitle(), data.getyAxisTitle());
}
// 构建中文类别→数值索引的映射(1,2,3...)
private Map<String, Integer> createCategoryIndexMap(String[] categories) {
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < categories.length; i++) {
map.put(categories[i], i + 1); // 索引从1开始,避免0值可能的问题
}
return map;
}
// 根据图表类型获取当前系列数据(复用父类的组合图逻辑)
private List<SeriesRenderData> getCurrentSeriesData(XDDFChartData chartData, ChartMultiSeriesRenderData data) {
if (chartData instanceof XDDFScatterChartData) {
// 散点图系列(可根据实际需求筛选)
return data.getSeriesDatas();
} else {
return obtainSeriesData(chartData.getClass(), data.getSeriesDatas());
}
}
// 为散点图创建 X 轴数据源(数值索引),其他图表用字符串标签
private XDDFDataSource<?> createCategoryDataSource(XWPFChart chart, XDDFChartData chartData,
ChartMultiSeriesRenderData data, Map<String, Integer> indexMap) {
if (chartData instanceof XDDFScatterChartData) {
// 散点图 X 轴用数值索引
Number[] indices = new Number[data.getCategories().length];
for (int i = 0; i < indices.length; i++) {
indices[i] = indexMap.get(data.getCategories()[i]);
}
return createNumbericalDataSource(chart, indices, 0);
} else {
// 其他图表用字符串标签
return createStringDataSource(chart, data.getCategories(), 0);
}
}
// 获取散点图的 X 坐标(数值索引)
private Number[] getScatterXValues(SeriesRenderData seriesData, String[] categories, Map<String, Integer> indexMap) {
// 若系列数据中已有 X 数值,则直接使用;否则根据类别生成
if (seriesData.getValues() != null && seriesData.getValues().length > 0) {
return seriesData.getValues();
} else {
// 按类别顺序生成数值索引
Number[] xValues = new Number[categories.length];
for (int i = 0; i < xValues.length; i++) {
xValues[i] = indexMap.get(categories[i]);
}
return xValues;
}
}
// 获取或创建系列(复用父类逻辑,确保样式一致)
private XDDFChartData.Series getOrCreateSeries(XDDFChartData chartData, int i, int orignSize,
XDDFDataSource<?> categoriesData, XDDFNumericalDataSource<?> valuesData) {
if (i < orignSize) {
XDDFChartData.Series series = chartData.getSeries(i);
series.replaceData(categoriesData, valuesData);
return series;
} else {
XDDFChartData.Series series = chartData.addSeries(categoriesData, valuesData);
processNewSeries(chartData, series);
return series;
}
}
}
代码解析
这个自定义插件
继承了POI-TL的
ScatterChartRenderPolicy
,主要重写了
MultiSeriesChartTemplateRenderPolicy
方法,并添加了几个关键的辅助方法:
doRender
:将中文类别转换为数字索引,解决中文无法作为散点图X轴坐标的问题
createCategoryIndexMap
:根据图表类型创建不同的X轴数据源,散点图使用数值索引,其他图表使用字符串标签
createCategoryDataSource
:专门为散点图获取X轴坐标值,优先使用系列数据中的值,否则使用类别对应的数值索引
getScatterXValues
:根据图表类型获取对应的系列数据,确保组合图表能正确工作
getCurrentSeriesData
通过这种方式,我们解决了散点图的中文类别问题,使用时判断是否散点图,指定自定义插件渲染。
使用示例
在配置POI-TL时,我们只需为散点图绑定这个自定义插件即可:
// 配置POI-TL
ConfigureBuilder builder = Configure.builder();
// 判断是否散点图
if (chartRenderUtil.isPureScatterChart(currChart)) {
// 散点图使用自定义插件渲染
builder.bind(targetTitle, new ScatterChartRenderPolicy());
}
// 准备数据
map.put(targetTitle, chartRenderUtil.buildMultiSeriesData(resultList));
// 渲染模板
XWPFTemplate template = XWPFTemplate.compile(templatePath, builder.build()).render(map);
其中
是一个工具方法,用于判断图表是否为散点图,
chartRenderUtil.isPureScatterChart
用于将业务数据转换为POI-TL所需的图表数据格式。
chartRenderUtil.buildMultiSeriesData
/**
* 判断 XWPFChart 是否为散点图(纯散点图,无其他类型图表混合)
* @param chart 目标图表
* @return true = 散点图;false = 非散点图
*/
public boolean isPureScatterChart(XWPFChart chart) {
CTChart ctChart = chart.getCTChart();
if (ctChart == null) return false;
CTPlotArea plotArea = ctChart.getPlotArea();
if (plotArea == null) return false;
// 1. 必须存在散点图容器
boolean hasScatterChart = plotArea.getScatterChartList() != null
&& !plotArea.getScatterChartList().isEmpty();
if (!hasScatterChart) return false;
// 2. 必须不存在其他类型图表容器(饼图、柱状图、折线图等)
boolean hasOtherChart = false;
// 检查饼图
if (plotArea.getPieChartList() != null && !plotArea.getPieChartList().isEmpty())
hasOtherChart = true;
// 检查柱状图
else if (plotArea.getBarChartList() != null && !plotArea.getBarChartList().isEmpty())
hasOtherChart = true;
// 检查折线图
else if (plotArea.getLineChartList() != null && !plotArea.getLineChartList().isEmpty())
hasOtherChart = true;
// 检查3D柱状图
else if (plotArea.getBar3DChartList() != null && !plotArea.getBar3DChartList().isEmpty())
hasOtherChart = true;
return !hasOtherChart;
}
总结
通过这个自定义插件,我们成功解决了POI-TL默认插件在处理中文类别散点图时的问题。这个方案的优势在于:
彻底解决了中文类别导致的散点图渲染错误保持了与其他图表类型的兼容性最小化修改原有逻辑,降低了维护成本保留了POI-TL原有的功能和扩展性
希望这个解决方案能帮助到遇到类似问题的开发者,也欢迎大家分享更多POI-TL的使用技巧和经验。