From 1c80c33bd6d88cc29db06f779efad36db1c0d740 Mon Sep 17 00:00:00 2001 From: tangwei Date: Mon, 7 Sep 2026 18:03:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=BA=86word?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=AF=BC=E5=87=BA=E5=B7=A5=E5=85=B7=E6=96=B9?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pom.xml | 8 + .../controller/WordChartExportController.java | 40 +++ .../qgc_export/domain/word/ChartType.java | 52 +++ .../domain/word/WordChartExportRequest.java | 24 ++ .../qgc_export/domain/word/WordChartVo.java | 39 +++ .../qgc_export/domain/word/WordSeriesVo.java | 24 ++ .../service/IWordChartExportService.java | 18 + .../impl/WordChartExportServiceImpl.java | 52 +++ .../qgc_export/util/ChartRenderUtil.java | 308 ++++++++++++++++++ .../qgc_export/util/WordChartExportUtil.java | 103 ++++++ 10 files changed, 668 insertions(+) create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/domain/word/ChartType.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartExportRequest.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/service/IWordChartExportService.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/service/impl/WordChartExportServiceImpl.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java diff --git a/backend/pom.xml b/backend/pom.xml index 507f58de..6a28bfa6 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,7 @@ 1.11.3 1.16 1.7.2 + 1.5.4 1.6.2 1.21 2.10.1 @@ -256,6 +257,13 @@ ${poi.ooxml.version} + + + org.jfree + jfreechart + ${jfreechart.version} + + com.alibaba diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java b/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java new file mode 100644 index 00000000..e6ae2f71 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java @@ -0,0 +1,40 @@ +package com.yfd.platform.qgc_export.controller; + +import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest; +import com.yfd.platform.qgc_export.service.IWordChartExportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Word 图表导出控制器 + *

+ * 支持饼图、柱状图、折线图、折柱混合图、堆叠柱状图、堆叠柱状图归一化(百分比)等图形导出到 Word。 + */ +@RestController +@RequestMapping("/qgcExport/word") +@Tag(name = "Word 图表导出") +@Validated +public class WordChartExportController { + + @Resource + private IWordChartExportService wordChartExportService; + + /** + * 导出 Word 文档(含多种图表) + * + * @param request 文档标题 + 图表列表 + * @param response HttpServletResponse + */ + @PostMapping("/export") + @Operation(summary = "导出 Word 文档(饼图/柱状图/折线图/折柱混合/堆叠/归一化堆叠)") + public void exportWord(@RequestBody WordChartExportRequest request, HttpServletResponse response) { + wordChartExportService.exportWord(request, response); + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/ChartType.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/ChartType.java new file mode 100644 index 00000000..b5390a5f --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/ChartType.java @@ -0,0 +1,52 @@ +package com.yfd.platform.qgc_export.domain.word; + +import lombok.Getter; + +/** + * Word 导出支持的图表类型 + */ +@Getter +public enum ChartType { + + /** 饼图 */ + PIE("饼图"), + /** 柱状图 */ + BAR("柱状图"), + /** 折线图 */ + LINE("折线图"), + /** 折柱混合图 */ + BAR_LINE("折柱混合图"), + /** 堆叠柱状图 */ + STACKED_BAR("堆叠柱状图"), + /** 堆叠柱状图(归一化/百分比) */ + PERCENT_STACKED_BAR("堆叠柱状图归一化"); + + private final String desc; + + ChartType(String desc) { + this.desc = desc; + } + + /** + * 忽略大小写解析图表类型 + */ + public static ChartType from(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim().replace("-", "_").toUpperCase(); + for (ChartType type : values()) { + if (type.name().equals(normalized)) { + return type; + } + } + // 兼容别名 + return switch (normalized) { + case "BARLINE", "BAR_LINE", "LINECOLUMN", "MIXED" -> BAR_LINE; + case "STACKED", "STACKEDBAR" -> STACKED_BAR; + case "PERCENT_STACKED", "PERCENTSTACKEDBAR", "NORMALIZED" -> PERCENT_STACKED_BAR; + case "COLUMN" -> BAR; + default -> null; + }; + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartExportRequest.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartExportRequest.java new file mode 100644 index 00000000..0d0f84e9 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartExportRequest.java @@ -0,0 +1,24 @@ +package com.yfd.platform.qgc_export.domain.word; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * Word 图表导出请求 + */ +@Data +@Schema(description = "Word 图表导出请求") +public class WordChartExportRequest { + + @Schema(description = "文档标题") + private String title; + + @Schema(description = "文档副标题/说明(可选)") + private String subtitle; + + @Schema(description = "图表列表,按顺序依次插入文档") + private List charts = new ArrayList<>(); +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java new file mode 100644 index 00000000..9ebeebc6 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java @@ -0,0 +1,39 @@ +package com.yfd.platform.qgc_export.domain.word; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * Word 文档中的单个图表描述 + */ +@Data +@Schema(description = "Word 图表") +public class WordChartVo { + + @Schema(description = "图表类型:pie/bar/line/barLine/stackedBar/percentStackedBar") + private String type; + + @Schema(description = "图表标题") + private String title; + + @Schema(description = "X 轴名称") + private String xAxisName; + + @Schema(description = "Y 轴(左轴)名称") + private String yAxisName; + + @Schema(description = "Y 轴(右轴)名称,折柱混合图用") + private String yAxisNameRight; + + @Schema(description = "X 轴分类(如月份、站点名等)") + private List categories = new ArrayList<>(); + + @Schema(description = "系列数据列表") + private List series = new ArrayList<>(); + + @Schema(description = "是否显示数值标签") + private boolean showValues = true; +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java new file mode 100644 index 00000000..e05c772c --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java @@ -0,0 +1,24 @@ +package com.yfd.platform.qgc_export.domain.word; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 图表中的单个系列(折柱混合图中可指定主轴/副轴) + */ +@Data +@Schema(description = "图表系列") +public class WordSeriesVo { + + @Schema(description = "系列名称(图例)") + private String name; + + @Schema(description = "系列数值,与 categories 一一对应") + private List values = new ArrayList<>(); + + @Schema(description = "Y 轴索引:0=主轴(左), 1=副轴(右),默认 0。折柱混合图用") + private Integer yAxisIndex = 0; +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/service/IWordChartExportService.java b/backend/src/main/java/com/yfd/platform/qgc_export/service/IWordChartExportService.java new file mode 100644 index 00000000..de008719 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/service/IWordChartExportService.java @@ -0,0 +1,18 @@ +package com.yfd.platform.qgc_export.service; + +import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Word 图表导出服务 + */ +public interface IWordChartExportService { + + /** + * 根据图表配置生成 Word 文档并写入响应 + * + * @param request 文档标题 + 图表列表 + * @param response HttpServletResponse + */ + void exportWord(WordChartExportRequest request, HttpServletResponse response); +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/WordChartExportServiceImpl.java b/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/WordChartExportServiceImpl.java new file mode 100644 index 00000000..0307db8d --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/WordChartExportServiceImpl.java @@ -0,0 +1,52 @@ +package com.yfd.platform.qgc_export.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest; +import com.yfd.platform.qgc_export.service.IWordChartExportService; +import com.yfd.platform.qgc_export.util.WordChartExportUtil; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Word 图表导出服务实现 + */ +@Service +public class WordChartExportServiceImpl implements IWordChartExportService { + + @Override + public void exportWord(WordChartExportRequest request, HttpServletResponse response) { + byte[] data; + try { + data = WordChartExportUtil.generateWord(request); + } catch (IOException e) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return; + } + + String fileName = resolveFileName(request); + response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentLength(data.length); + response.setHeader("Content-Disposition", + "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); + + try (OutputStream os = response.getOutputStream()) { + os.write(data); + os.flush(); + } catch (IOException e) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + private String resolveFileName(WordChartExportRequest request) { + if (request != null && StrUtil.isNotBlank(request.getTitle())) { + return request.getTitle() + ".docx"; + } + return "图表报告.docx"; + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java b/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java new file mode 100644 index 00000000..b5e8a77f --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java @@ -0,0 +1,308 @@ +package com.yfd.platform.qgc_export.util; + +import com.yfd.platform.qgc_export.domain.word.ChartType; +import com.yfd.platform.qgc_export.domain.word.WordChartVo; +import com.yfd.platform.qgc_export.domain.word.WordSeriesVo; +import org.jfree.chart.ChartFactory; +import org.jfree.chart.JFreeChart; +import org.jfree.chart.StandardChartTheme; +import org.jfree.chart.axis.CategoryAxis; +import org.jfree.chart.axis.NumberAxis; +import org.jfree.chart.labels.StandardCategoryItemLabelGenerator; +import org.jfree.chart.labels.StandardPieSectionLabelGenerator; +import org.jfree.chart.plot.CategoryPlot; +import org.jfree.chart.plot.DatasetRenderingOrder; +import org.jfree.chart.plot.PiePlot; +import org.jfree.chart.plot.PlotOrientation; +import org.jfree.chart.renderer.category.BarRenderer; +import org.jfree.chart.renderer.category.LineAndShapeRenderer; +import org.jfree.chart.renderer.category.StackedBarRenderer; +import org.jfree.chart.ui.RectangleInsets; +import org.jfree.data.category.DefaultCategoryDataset; +import org.jfree.data.general.DefaultPieDataset; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.image.BufferedImage; +import java.text.DecimalFormat; +import java.util.List; + +/** + * 图表渲染工具:将结构化图表数据渲染为 BufferedImage,供 Word 导出插入。 + *

+ * 支持:饼图、柱状图、折线图、折柱混合图、堆叠柱状图、堆叠柱状图归一化(百分比)。 + */ +public final class ChartRenderUtil { + + /** 默认渲染宽度 */ + private static final int DEFAULT_WIDTH = 900; + /** 默认渲染高度 */ + private static final int DEFAULT_HEIGHT = 520; + /** 中文字体 */ + private static final String FONT_NAME = "SimSun"; + + static { + // 统一主题,避免中文乱码,去除阴影 + StandardChartTheme theme = (StandardChartTheme) StandardChartTheme.createJFreeTheme(); + Font extraLarge = new Font(FONT_NAME, Font.BOLD, 20); + Font large = new Font(FONT_NAME, Font.BOLD, 14); + Font regular = new Font(FONT_NAME, Font.PLAIN, 12); + Font small = new Font(FONT_NAME, Font.PLAIN, 10); + theme.setExtraLargeFont(extraLarge); + theme.setLargeFont(large); + theme.setRegularFont(regular); + theme.setSmallFont(small); + theme.setShadowVisible(false); + ChartFactory.setChartTheme(theme); + } + + private ChartRenderUtil() { + } + + /** + * 渲染图表为图片 + */ + public static BufferedImage render(WordChartVo chart) { + ChartType chartType = ChartType.from(chart.getType()); + if (chartType == null) { + throw new IllegalArgumentException("不支持的图表类型: " + chart.getType()); + } + JFreeChart jfreeChart = switch (chartType) { + case PIE -> buildPieChart(chart); + case BAR -> buildBarChart(chart); + case LINE -> buildLineChart(chart); + case BAR_LINE -> buildBarLineChart(chart); + case STACKED_BAR -> buildStackedBarChart(chart, false); + case PERCENT_STACKED_BAR -> buildStackedBarChart(chart, true); + }; + if (jfreeChart == null) { + return null; + } + jfreeChart.setBackgroundPaint(Color.WHITE); + return jfreeChart.createBufferedImage(DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + private static JFreeChart buildPieChart(WordChartVo chart) { + DefaultPieDataset dataset = new DefaultPieDataset<>(); + List categories = chart.getCategories(); + WordSeriesVo series = chart.getSeries().isEmpty() ? null : chart.getSeries().get(0); + if (categories == null || categories.isEmpty() || series == null || series.getValues() == null) { + return null; + } + List values = series.getValues(); + for (int i = 0; i < categories.size(); i++) { + Double value = i < values.size() ? values.get(i) : 0.0; + dataset.setValue(categories.get(i), value == null ? 0.0 : value); + } + + JFreeChart jfreeChart = ChartFactory.createPieChart( + chart.getTitle(), dataset, true, true, false); + PiePlot plot = (PiePlot) jfreeChart.getPlot(); + plot.setLabelGenerator(new StandardPieSectionLabelGenerator( + "{0}: {1} ({2})", new DecimalFormat("0.##"), new DecimalFormat("0.0%"))); + plot.setLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); + plot.setLegendLabelGenerator(new StandardPieSectionLabelGenerator("{0}")); + plot.setBackgroundPaint(Color.WHITE); + plot.setOutlineVisible(false); + plot.setLabelBackgroundPaint(new Color(255, 255, 255, 200)); + plot.setLabelOutlinePaint(null); + plot.setLabelShadowPaint(null); + return jfreeChart; + } + + private static JFreeChart buildBarChart(WordChartVo chart) { + DefaultCategoryDataset dataset = buildDataset(chart); + JFreeChart jfreeChart = ChartFactory.createBarChart( + chart.getTitle(), + chart.getXAxisName(), + chart.getYAxisName(), + dataset, + PlotOrientation.VERTICAL, + true, true, false); + CategoryPlot plot = jfreeChart.getCategoryPlot(); + stylePlot(plot); + BarRenderer renderer = (BarRenderer) plot.getRenderer(); + renderer.setDefaultItemLabelsVisible(chart.isShowValues()); + renderer.setDefaultItemLabelGenerator( + new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); + renderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10)); + renderer.setItemMargin(0.1); + return jfreeChart; + } + + private static JFreeChart buildLineChart(WordChartVo chart) { + DefaultCategoryDataset dataset = buildDataset(chart); + JFreeChart jfreeChart = ChartFactory.createLineChart( + chart.getTitle(), + chart.getXAxisName(), + chart.getYAxisName(), + dataset, + PlotOrientation.VERTICAL, + true, true, false); + CategoryPlot plot = jfreeChart.getCategoryPlot(); + stylePlot(plot); + LineAndShapeRenderer renderer = (LineAndShapeRenderer) plot.getRenderer(); + renderer.setDefaultShapesVisible(true); + renderer.setDefaultShapesFilled(true); + renderer.setDefaultStroke(new BasicStroke(2.0f)); + renderer.setDefaultItemLabelsVisible(chart.isShowValues()); + renderer.setDefaultItemLabelGenerator( + new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); + renderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10)); + return jfreeChart; + } + + private static JFreeChart buildStackedBarChart(WordChartVo chart, boolean percent) { + DefaultCategoryDataset dataset = buildDataset(chart); + if (percent) { + dataset = normalizePercent(dataset); + } + JFreeChart jfreeChart = ChartFactory.createStackedBarChart( + chart.getTitle(), + chart.getXAxisName(), + percent ? (chart.getYAxisName() == null ? "占比(%)" : chart.getYAxisName()) : chart.getYAxisName(), + dataset, + PlotOrientation.VERTICAL, + true, true, false); + CategoryPlot plot = jfreeChart.getCategoryPlot(); + stylePlot(plot); + StackedBarRenderer renderer = (StackedBarRenderer) plot.getRenderer(); + renderer.setDefaultItemLabelsVisible(chart.isShowValues()); + if (percent) { + renderer.setDefaultItemLabelGenerator( + new StandardCategoryItemLabelGenerator("{2}%", new DecimalFormat("0.##"))); + NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis(); + rangeAxis.setRange(0.0, 100.0); + rangeAxis.setNumberFormatOverride(new DecimalFormat("0'%'")); + } else { + renderer.setDefaultItemLabelGenerator( + new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); + } + renderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10)); + return jfreeChart; + } + + private static JFreeChart buildBarLineChart(WordChartVo chart) { + DefaultCategoryDataset barDataset = new DefaultCategoryDataset(); + DefaultCategoryDataset lineDataset = new DefaultCategoryDataset(); + boolean hasBar = false; + boolean hasLine = false; + for (WordSeriesVo series : chart.getSeries()) { + if (series == null || series.getName() == null || series.getValues() == null) { + continue; + } + boolean onRight = series.getYAxisIndex() != null && series.getYAxisIndex() == 1; + DefaultCategoryDataset target = onRight ? lineDataset : barDataset; + if (onRight) { + hasLine = true; + } else { + hasBar = true; + } + for (int i = 0; i < chart.getCategories().size(); i++) { + Double value = i < series.getValues().size() ? series.getValues().get(i) : null; + target.addValue(value == null ? 0.0 : value, series.getName(), chart.getCategories().get(i)); + } + } + + // 若全部被归到一个轴,至少保证主轴有数据 + if (!hasBar && hasLine) { + return buildLineChart(chart); + } + + CategoryAxis domainAxis = new CategoryAxis(chart.getXAxisName()); + domainAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); + NumberAxis leftAxis = new NumberAxis(chart.getYAxisName()); + leftAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); + NumberAxis rightAxis = new NumberAxis(chart.getYAxisNameRight()); + rightAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); + + BarRenderer barRenderer = new BarRenderer(); + barRenderer.setDefaultItemLabelsVisible(chart.isShowValues()); + barRenderer.setDefaultItemLabelGenerator( + new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); + barRenderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10)); + + LineAndShapeRenderer lineRenderer = new LineAndShapeRenderer(); + lineRenderer.setDefaultShapesVisible(true); + lineRenderer.setDefaultShapesFilled(true); + lineRenderer.setDefaultStroke(new BasicStroke(2.0f)); + lineRenderer.setDefaultItemLabelsVisible(chart.isShowValues()); + lineRenderer.setDefaultItemLabelGenerator( + new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); + lineRenderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10)); + + CategoryPlot plot = new CategoryPlot(barDataset, domainAxis, leftAxis, barRenderer); + plot.setDataset(1, lineDataset); + plot.setRenderer(1, lineRenderer); + plot.setRangeAxis(1, rightAxis); + plot.mapDatasetToRangeAxis(0, 0); + plot.mapDatasetToRangeAxis(1, 1); + plot.setDatasetRenderingOrder(DatasetRenderingOrder.FORWARD); + plot.setBackgroundPaint(Color.WHITE); + plot.setDomainGridlinesVisible(true); + plot.setRangeGridlinesVisible(true); + plot.setAxisOffset(new RectangleInsets(5.0, 5.0, 5.0, 5.0)); + plot.setDomainGridlinePaint(Color.LIGHT_GRAY); + plot.setRangeGridlinePaint(Color.LIGHT_GRAY); + + return new JFreeChart(chart.getTitle(), JFreeChart.DEFAULT_TITLE_FONT, plot, true); + } + + private static DefaultCategoryDataset buildDataset(WordChartVo chart) { + DefaultCategoryDataset dataset = new DefaultCategoryDataset(); + List categories = chart.getCategories(); + if (categories == null || categories.isEmpty()) { + return dataset; + } + for (WordSeriesVo series : chart.getSeries()) { + if (series == null || series.getName() == null || series.getValues() == null) { + continue; + } + for (int i = 0; i < categories.size(); i++) { + Double value = i < series.getValues().size() ? series.getValues().get(i) : null; + dataset.addValue(value == null ? 0.0 : value, series.getName(), categories.get(i)); + } + } + return dataset; + } + + /** + * 将数据按列(分类)归一化为百分比,用于归一化堆叠柱状图 + */ + private static DefaultCategoryDataset normalizePercent(DefaultCategoryDataset source) { + DefaultCategoryDataset result = new DefaultCategoryDataset(); + int rowCount = source.getRowCount(); + int columnCount = source.getColumnCount(); + if (rowCount == 0 || columnCount == 0) { + return result; + } + for (int col = 0; col < columnCount; col++) { + double sum = 0.0; + for (int row = 0; row < rowCount; row++) { + Number value = source.getValue(row, col); + sum += value == null ? 0.0 : value.doubleValue(); + } + for (int row = 0; row < rowCount; row++) { + Number value = source.getValue(row, col); + double v = value == null ? 0.0 : value.doubleValue(); + double percent = sum == 0.0 ? 0.0 : v / sum * 100.0; + result.addValue(percent, source.getRowKey(row), source.getColumnKey(col)); + } + } + return result; + } + + private static void stylePlot(CategoryPlot plot) { + plot.setBackgroundPaint(Color.WHITE); + plot.setDomainGridlinesVisible(true); + plot.setRangeGridlinesVisible(true); + plot.setDomainGridlinePaint(Color.LIGHT_GRAY); + plot.setRangeGridlinePaint(Color.LIGHT_GRAY); + plot.setAxisOffset(new RectangleInsets(5.0, 5.0, 5.0, 5.0)); + CategoryAxis domainAxis = plot.getDomainAxis(); + domainAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); + NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis(); + rangeAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); + } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java b/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java new file mode 100644 index 00000000..e7df7274 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java @@ -0,0 +1,103 @@ +package com.yfd.platform.qgc_export.util; + +import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest; +import com.yfd.platform.qgc_export.domain.word.WordChartVo; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.Document; +import org.apache.poi.xwpf.usermodel.ParagraphAlignment; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +/** + * Word 图表导出工具:将多个图表渲染为图片并按顺序写入 .docx 文档。 + */ +public final class WordChartExportUtil { + + /** 插入图片的目标宽度(单位:磅,1 磅 = 1/72 英寸) */ + private static final double IMAGE_WIDTH_PT = 460.0; + + private WordChartExportUtil() { + } + + /** + * 生成 Word 文档字节数组 + * + * @param request 文档标题 + 图表列表 + * @return .docx 字节数组 + */ + public static byte[] generateWord(WordChartExportRequest request) throws IOException { + List charts = request == null ? List.of() : request.getCharts(); + try (XWPFDocument document = new XWPFDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + appendTitle(document, request); + for (WordChartVo chart : charts) { + appendChart(document, chart); + } + document.write(out); + return out.toByteArray(); + } + } + + private static void appendTitle(XWPFDocument document, WordChartExportRequest request) { + String title = request == null ? null : request.getTitle(); + if (title != null && !title.isBlank()) { + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setAlignment(ParagraphAlignment.CENTER); + XWPFRun run = paragraph.createRun(); + run.setText(title); + run.setBold(true); + run.setFontSize(22); + run.setFontFamily("SimSun"); + } + String subtitle = request == null ? null : request.getSubtitle(); + if (subtitle != null && !subtitle.isBlank()) { + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setAlignment(ParagraphAlignment.CENTER); + XWPFRun run = paragraph.createRun(); + run.setText(subtitle); + run.setFontSize(12); + run.setColor("808080"); + run.setFontFamily("SimSun"); + } + } + + private static void appendChart(XWPFDocument document, WordChartVo chart) throws IOException { + BufferedImage image = ChartRenderUtil.render(chart); + if (image == null) { + return; + } + byte[] png = toPngBytes(image); + double imageHeightPt = IMAGE_WIDTH_PT * image.getHeight() / image.getWidth(); + + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setAlignment(ParagraphAlignment.CENTER); + XWPFRun run = paragraph.createRun(); + try (ByteArrayInputStream in = new ByteArrayInputStream(png)) { + try { + run.addPicture(in, Document.PICTURE_TYPE_PNG, "chart.png", + Units.toEMU(IMAGE_WIDTH_PT), Units.toEMU(imageHeightPt)); + } catch (org.apache.poi.openxml4j.exceptions.InvalidFormatException e) { + throw new IOException("插入图片失败", e); + } + } + // 每个图表后空一行,避免图片粘连 + XWPFParagraph spacer = document.createParagraph(); + spacer.setSpacingAfter(120); + } + + private static byte[] toPngBytes(BufferedImage image) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", out); + return out.toByteArray(); + } + } +}