fix: 优化word文档导出

This commit is contained in:
tangwei 2026-09-08 08:27:37 +08:00
parent 1c80c33bd6
commit c1ede68b1a
5 changed files with 284 additions and 1 deletions

View File

@ -1,17 +1,23 @@
package com.yfd.platform.qgc_export.controller; package com.yfd.platform.qgc_export.controller;
import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest; import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest;
import com.yfd.platform.qgc_export.domain.word.WordChartVo;
import com.yfd.platform.qgc_export.domain.word.WordSeriesVo;
import com.yfd.platform.qgc_export.service.IWordChartExportService; import com.yfd.platform.qgc_export.service.IWordChartExportService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
/** /**
* Word 图表导出控制器 * Word 图表导出控制器
* <p> * <p>
@ -37,4 +43,133 @@ public class WordChartExportController {
public void exportWord(@RequestBody WordChartExportRequest request, HttpServletResponse response) { public void exportWord(@RequestBody WordChartExportRequest request, HttpServletResponse response) {
wordChartExportService.exportWord(request, response); wordChartExportService.exportWord(request, response);
} }
/**
* 返回一份带多种图表多系列自定义颜色的模拟请求数据便于前端参考或直接复制
*/
@GetMapping("/demo")
@Operation(summary = "获取模拟导出请求数据(含 6 种图表类型 + 自定义颜色)")
public WordChartExportRequest demo() {
return buildDemoRequest();
}
private WordChartExportRequest buildDemoRequest() {
WordChartExportRequest request = new WordChartExportRequest();
request.setTitle("年度水电监测数据分析报告");
request.setSubtitle("演示数据(含饼图/柱状图/折线图/折柱混合/堆叠/归一化堆叠)");
List<String> months = Arrays.asList("1月", "2月", "3月", "4月", "5月", "6月");
// 1. 饼图各站类设备数量占比相邻分片留白 + 自定义颜色
WordChartVo pie = new WordChartVo();
pie.setType("pie");
pie.setHeading("第一章 设备总体情况");
pie.setSubHeading("1.1 各站类设备数量占比");
pie.setTitle("各站类设备数量占比");
pie.setDescription("图 1-1 各站类设备数量占比示意图");
pie.setCategories(Arrays.asList("水质站", "水文站", "水温站", "视频设备", "AI分拣装置"));
pie.setColors(Arrays.asList("#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B2"));
pie.setPieGap(3);
WordSeriesVo pieSeries = new WordSeriesVo();
pieSeries.setName("数量");
pieSeries.setValues(Arrays.asList(120.0, 85.0, 60.0, 45.0, 30.0));
pie.setSeries(List.of(pieSeries));
// 2. 柱状图多系列 + 自定义颜色
WordChartVo bar = new WordChartVo();
bar.setType("bar");
bar.setHeading("第二章 基地设备分布");
bar.setSubHeading("2.1 各基地设备数量对比");
bar.setTitle("各基地设备数量对比");
bar.setDescription("图 2-1 各基地设备数量对比示意图");
bar.setXAxisName("基地");
bar.setYAxisName("数量(台)");
bar.setCategories(Arrays.asList("班多基地", "羊曲基地", "茨哈峡基地", "龙羊峡基地"));
WordSeriesVo bar1 = series("智能装备", "#4C72B0", 120.0, 90.0, 150.0, 80.0);
WordSeriesVo bar2 = series("监测设备", "#55A868", 60.0, 70.0, 95.0, 110.0);
WordSeriesVo bar3 = series("视频设备", "#DD8452", 40.0, 50.0, 60.0, 70.0);
bar.setSeries(Arrays.asList(bar1, bar2, bar3));
// 3. 折线图月度趋势
WordChartVo line = new WordChartVo();
line.setType("line");
line.setHeading("第三章 生态流量趋势");
line.setSubHeading("3.1 月度达标率趋势");
line.setTitle("月度生态流量达标率趋势");
line.setDescription("图 3-1 2024/2025 年月度生态流量达标率变化趋势");
line.setXAxisName("月份");
line.setYAxisName("达标率(%)");
line.setCategories(months);
WordSeriesVo line1 = series("2024年", "#C44E52", 92.0, 94.0, 90.0, 95.0, 96.0, 93.0);
WordSeriesVo line2 = series("2025年", "#4C72B0", 88.0, 91.0, 93.0, 92.0, 97.0, 98.0);
line.setSeries(Arrays.asList(line1, line2));
// 4. 折柱混合图主轴+ 折线副轴
WordChartVo barLine = new WordChartVo();
barLine.setType("barLine");
barLine.setHeading("第四章 水量水质综合");
barLine.setSubHeading("4.1 水量与浊度综合趋势");
barLine.setTitle("水量与水质综合趋势");
barLine.setDescription("图 4-1 下泄水量(柱)与浊度(折线)综合趋势");
barLine.setXAxisName("月份");
barLine.setYAxisName("下泄水量(万m³)");
barLine.setYAxisNameRight("浊度(NTU)");
barLine.setCategories(months);
WordSeriesVo barSeries = series("下泄水量", "#55A868", 0, 320.0, 350.0, 410.0, 500.0, 620.0, 780.0);
WordSeriesVo lineSeries = series("浊度", "#C44E52", 1, 12.0, 11.0, 9.0, 8.0, 6.0, 5.0);
barLine.setSeries(Arrays.asList(barSeries, lineSeries));
// 5. 堆叠柱状图
WordChartVo stacked = new WordChartVo();
stacked.setType("stackedBar");
stacked.setHeading("第五章 基地设备构成");
stacked.setSubHeading("5.1 设备类型构成");
stacked.setTitle("各基地设备类型构成");
stacked.setDescription("图 5-1 各基地设备类型构成堆叠图");
stacked.setXAxisName("基地");
stacked.setYAxisName("数量(台)");
stacked.setCategories(Arrays.asList("班多基地", "羊曲基地", "茨哈峡基地"));
stacked.setSeries(Arrays.asList(
series("水质", "#4C72B0", 30.0, 20.0, 40.0),
series("水文", "#55A868", 25.0, 35.0, 30.0),
series("视频", "#DD8452", 15.0, 25.0, 20.0)
));
// 6. 归一化堆叠柱状图百分比
WordChartVo percent = new WordChartVo();
percent.setType("percentStackedBar");
percent.setHeading("第五章 基地设备构成");
percent.setSubHeading("5.2 设备类型占比(归一化)");
percent.setTitle("各基地设备类型占比(归一化)");
percent.setDescription("图 5-2 各基地设备类型占比归一化堆叠图");
percent.setXAxisName("基地");
percent.setYAxisName("占比(%)");
percent.setCategories(Arrays.asList("班多基地", "羊曲基地", "茨哈峡基地"));
percent.setSeries(Arrays.asList(
series("水质", "#4C72B0", 30.0, 20.0, 40.0),
series("水文", "#55A868", 25.0, 35.0, 30.0),
series("视频", "#DD8452", 15.0, 25.0, 20.0)
));
request.setCharts(Arrays.asList(pie, bar, line, barLine, stacked, percent));
return request;
}
private WordSeriesVo series(String name, String color, double... values) {
WordSeriesVo vo = new WordSeriesVo();
vo.setName(name);
vo.setColor(color);
List<Double> list = new java.util.ArrayList<>();
for (double v : values) {
list.add(v);
}
vo.setValues(list);
return vo;
}
private WordSeriesVo series(String name, String color, int yAxisIndex, double... values) {
WordSeriesVo vo = series(name, color, values);
vo.setYAxisIndex(yAxisIndex);
return vo;
}
} }

View File

@ -16,9 +16,18 @@ public class WordChartVo {
@Schema(description = "图表类型pie/bar/line/barLine/stackedBar/percentStackedBar") @Schema(description = "图表类型pie/bar/line/barLine/stackedBar/percentStackedBar")
private String type; private String type;
@Schema(description = "图表标题") @Schema(description = "图表标题(显示在图片内部)")
private String title; private String title;
@Schema(description = "Word 文档中的一级标题(章节标题),显示在图片上方,可选")
private String heading;
@Schema(description = "Word 文档中的二级标题,显示在图片上方、一级标题之后,可选")
private String subHeading;
@Schema(description = "图表描述/图注文字,显示在图片下方,可选")
private String description;
@Schema(description = "X 轴名称") @Schema(description = "X 轴名称")
private String xAxisName; private String xAxisName;
@ -34,6 +43,12 @@ public class WordChartVo {
@Schema(description = "系列数据列表") @Schema(description = "系列数据列表")
private List<WordSeriesVo> series = new ArrayList<>(); private List<WordSeriesVo> series = new ArrayList<>();
@Schema(description = "饼图各分片颜色,与 categories 一一对应,支持 #RRGGBB / 颜色名,不传则用默认配色")
private List<String> colors = new ArrayList<>();
@Schema(description = "饼图扇区之间留白宽度(像素),默认 2用于相邻种类之间留空隙")
private Integer pieGap = 2;
@Schema(description = "是否显示数值标签") @Schema(description = "是否显示数值标签")
private boolean showValues = true; private boolean showValues = true;
} }

View File

@ -21,4 +21,7 @@ public class WordSeriesVo {
@Schema(description = "Y 轴索引0=主轴(左), 1=副轴(右),默认 0。折柱混合图用") @Schema(description = "Y 轴索引0=主轴(左), 1=副轴(右),默认 0。折柱混合图用")
private Integer yAxisIndex = 0; private Integer yAxisIndex = 0;
@Schema(description = "系列颜色,支持 #RRGGBB / #AARRGGBB / 颜色名(如 red),不传则用默认配色")
private String color;
} }

View File

@ -15,6 +15,7 @@ import org.jfree.chart.plot.DatasetRenderingOrder;
import org.jfree.chart.plot.PiePlot; import org.jfree.chart.plot.PiePlot;
import org.jfree.chart.plot.PlotOrientation; import org.jfree.chart.plot.PlotOrientation;
import org.jfree.chart.renderer.category.BarRenderer; import org.jfree.chart.renderer.category.BarRenderer;
import org.jfree.chart.renderer.category.CategoryItemRenderer;
import org.jfree.chart.renderer.category.LineAndShapeRenderer; import org.jfree.chart.renderer.category.LineAndShapeRenderer;
import org.jfree.chart.renderer.category.StackedBarRenderer; import org.jfree.chart.renderer.category.StackedBarRenderer;
import org.jfree.chart.ui.RectangleInsets; import org.jfree.chart.ui.RectangleInsets;
@ -25,6 +26,7 @@ import java.awt.BasicStroke;
import java.awt.Color; import java.awt.Color;
import java.awt.Font; import java.awt.Font;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.lang.reflect.Field;
import java.text.DecimalFormat; import java.text.DecimalFormat;
import java.util.List; import java.util.List;
@ -108,6 +110,20 @@ public final class ChartRenderUtil {
plot.setLabelBackgroundPaint(new Color(255, 255, 255, 200)); plot.setLabelBackgroundPaint(new Color(255, 255, 255, 200));
plot.setLabelOutlinePaint(null); plot.setLabelOutlinePaint(null);
plot.setLabelShadowPaint(null); plot.setLabelShadowPaint(null);
// 自定义分片颜色
List<String> colors = chart.getColors();
for (int i = 0; i < categories.size(); i++) {
Color color = parseColor(i < colors.size() ? colors.get(i) : null);
if (color != null) {
plot.setSectionPaint(categories.get(i), color);
}
}
// 相邻分片之间留白白色描边形成空隙
float gap = chart.getPieGap() == null ? 2f : chart.getPieGap().floatValue();
plot.setSectionOutlinesVisible(true);
plot.setDefaultSectionOutlinePaint(Color.WHITE);
plot.setDefaultSectionOutlineStroke(new BasicStroke(Math.max(gap, 0f)));
return jfreeChart; return jfreeChart;
} }
@ -123,6 +139,7 @@ public final class ChartRenderUtil {
CategoryPlot plot = jfreeChart.getCategoryPlot(); CategoryPlot plot = jfreeChart.getCategoryPlot();
stylePlot(plot); stylePlot(plot);
BarRenderer renderer = (BarRenderer) plot.getRenderer(); BarRenderer renderer = (BarRenderer) plot.getRenderer();
applySeriesPaints(renderer, chart.getSeries());
renderer.setDefaultItemLabelsVisible(chart.isShowValues()); renderer.setDefaultItemLabelsVisible(chart.isShowValues());
renderer.setDefaultItemLabelGenerator( renderer.setDefaultItemLabelGenerator(
new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##")));
@ -143,6 +160,7 @@ public final class ChartRenderUtil {
CategoryPlot plot = jfreeChart.getCategoryPlot(); CategoryPlot plot = jfreeChart.getCategoryPlot();
stylePlot(plot); stylePlot(plot);
LineAndShapeRenderer renderer = (LineAndShapeRenderer) plot.getRenderer(); LineAndShapeRenderer renderer = (LineAndShapeRenderer) plot.getRenderer();
applySeriesPaints(renderer, chart.getSeries());
renderer.setDefaultShapesVisible(true); renderer.setDefaultShapesVisible(true);
renderer.setDefaultShapesFilled(true); renderer.setDefaultShapesFilled(true);
renderer.setDefaultStroke(new BasicStroke(2.0f)); renderer.setDefaultStroke(new BasicStroke(2.0f));
@ -168,6 +186,7 @@ public final class ChartRenderUtil {
CategoryPlot plot = jfreeChart.getCategoryPlot(); CategoryPlot plot = jfreeChart.getCategoryPlot();
stylePlot(plot); stylePlot(plot);
StackedBarRenderer renderer = (StackedBarRenderer) plot.getRenderer(); StackedBarRenderer renderer = (StackedBarRenderer) plot.getRenderer();
applySeriesPaints(renderer, chart.getSeries());
renderer.setDefaultItemLabelsVisible(chart.isShowValues()); renderer.setDefaultItemLabelsVisible(chart.isShowValues());
if (percent) { if (percent) {
renderer.setDefaultItemLabelGenerator( renderer.setDefaultItemLabelGenerator(
@ -232,6 +251,28 @@ public final class ChartRenderUtil {
new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##")));
lineRenderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10)); lineRenderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10));
// 混合图/线系列各自独立编号分别应用自定义颜色
int barIdx = 0;
int lineIdx = 0;
for (WordSeriesVo series : chart.getSeries()) {
if (series == null || series.getName() == null || series.getValues() == null) {
continue;
}
boolean onRight = series.getYAxisIndex() != null && series.getYAxisIndex() == 1;
Color color = parseColor(series.getColor());
if (onRight) {
if (color != null) {
lineRenderer.setSeriesPaint(lineIdx, color);
}
lineIdx++;
} else {
if (color != null) {
barRenderer.setSeriesPaint(barIdx, color);
}
barIdx++;
}
}
CategoryPlot plot = new CategoryPlot(barDataset, domainAxis, leftAxis, barRenderer); CategoryPlot plot = new CategoryPlot(barDataset, domainAxis, leftAxis, barRenderer);
plot.setDataset(1, lineDataset); plot.setDataset(1, lineDataset);
plot.setRenderer(1, lineRenderer); plot.setRenderer(1, lineRenderer);
@ -305,4 +346,54 @@ public final class ChartRenderUtil {
NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis(); NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
rangeAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); rangeAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12));
} }
/**
* 按系列顺序给渲染器应用自定义颜色跳过无效系列
*/
private static void applySeriesPaints(CategoryItemRenderer renderer, List<WordSeriesVo> seriesList) {
if (renderer == null || seriesList == null) {
return;
}
int idx = 0;
for (WordSeriesVo series : seriesList) {
if (series == null || series.getName() == null || series.getValues() == null) {
continue;
}
Color color = parseColor(series.getColor());
if (color != null) {
renderer.setSeriesPaint(idx, color);
}
idx++;
}
}
/**
* 解析颜色支持 #RRGGBB / #AARRGGBB / 0xRRGGBB / Java 颜色名( red)
*/
private static Color parseColor(String color) {
if (color == null || color.isBlank()) {
return null;
}
String c = color.trim();
try {
if (c.startsWith("#")) {
String hex = c.substring(1);
if (hex.length() == 6) {
return new Color(Integer.parseInt(hex, 16));
} else if (hex.length() == 8) {
return new Color(Integer.parseInt(hex.substring(2), 16), true);
}
} else if (c.startsWith("0x") || c.startsWith("0X")) {
return new Color(Integer.parseInt(c.substring(2), 16));
} else {
Field field = Color.class.getField(c.toUpperCase());
if (field.getType() == Color.class) {
return (Color) field.get(null);
}
}
} catch (Exception ignored) {
// 解析失败则返回 null使用默认配色
}
return null;
}
} }

View File

@ -78,6 +78,12 @@ public final class WordChartExportUtil {
byte[] png = toPngBytes(image); byte[] png = toPngBytes(image);
double imageHeightPt = IMAGE_WIDTH_PT * image.getHeight() / image.getWidth(); double imageHeightPt = IMAGE_WIDTH_PT * image.getHeight() / image.getWidth();
// 一级标题
appendHeading(document, chart.getHeading(), 16, true);
// 二级标题
appendHeading(document, chart.getSubHeading(), 13, true);
// 图片
XWPFParagraph paragraph = document.createParagraph(); XWPFParagraph paragraph = document.createParagraph();
paragraph.setAlignment(ParagraphAlignment.CENTER); paragraph.setAlignment(ParagraphAlignment.CENTER);
XWPFRun run = paragraph.createRun(); XWPFRun run = paragraph.createRun();
@ -89,11 +95,44 @@ public final class WordChartExportUtil {
throw new IOException("插入图片失败", e); throw new IOException("插入图片失败", e);
} }
} }
// 图表描述/图注
appendDescription(document, chart.getDescription());
// 每个图表后空一行避免图片粘连 // 每个图表后空一行避免图片粘连
XWPFParagraph spacer = document.createParagraph(); XWPFParagraph spacer = document.createParagraph();
spacer.setSpacingAfter(120); spacer.setSpacingAfter(120);
} }
private static void appendHeading(XWPFDocument document, String text, int fontSize, boolean bold) {
if (text == null || text.isBlank()) {
return;
}
XWPFParagraph paragraph = document.createParagraph();
paragraph.setAlignment(ParagraphAlignment.LEFT);
paragraph.setSpacingBefore(160);
paragraph.setSpacingAfter(80);
XWPFRun run = paragraph.createRun();
run.setText(text);
run.setBold(bold);
run.setFontSize(fontSize);
run.setFontFamily("SimSun");
}
private static void appendDescription(XWPFDocument document, String text) {
if (text == null || text.isBlank()) {
return;
}
XWPFParagraph paragraph = document.createParagraph();
paragraph.setAlignment(ParagraphAlignment.CENTER);
paragraph.setSpacingBefore(60);
XWPFRun run = paragraph.createRun();
run.setText(text);
run.setFontSize(11);
run.setColor("808080");
run.setFontFamily("SimSun");
}
private static byte[] toPngBytes(BufferedImage image) throws IOException { private static byte[] toPngBytes(BufferedImage image) throws IOException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", out); ImageIO.write(image, "png", out);