diff --git a/src/main/java/com/biutag/supervision/support/FontDiagnosticRunner.java b/src/main/java/com/biutag/supervision/support/FontDiagnosticRunner.java new file mode 100644 index 0000000..0046a62 --- /dev/null +++ b/src/main/java/com/biutag/supervision/support/FontDiagnosticRunner.java @@ -0,0 +1,91 @@ +package com.biutag.supervision.support; + +import com.biutag.supervision.util.ChartRenderUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.awt.Font; +import java.awt.GraphicsEnvironment; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +@Component +public class FontDiagnosticRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(FontDiagnosticRunner.class); + + private static final String SAMPLE_TEXT = "查实情况分布单位问题数量暂无数据"; + + private static final String[] CANDIDATES = new String[]{ + "Microsoft YaHei", "微软雅黑", + "PingFang SC", "苹方-简", + "Noto Sans CJK SC", "Noto Sans SC", + "Source Han Sans CN", "Source Han Sans SC", + "WenQuanYi Micro Hei", "文泉驿微米黑", + "SimHei", "黑体", + "SimSun", "宋体", + "Arial Unicode MS", + "SansSerif" + }; + + @Override + public void run(ApplicationArguments args) { + ChartRenderUtil.registerFontFromResource("/fonts/NotoSansCJKsc-Regular.otf"); + ChartRenderUtil.registerFontFromResource("/fonts/NotoSansCJKsc-Bold.otf"); + diagnose(); + } + + public static void diagnose() { + try { + boolean headless = GraphicsEnvironment.isHeadless(); + GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); + String[] availableFonts = ge.getAvailableFontFamilyNames(); + Set fontSet = new LinkedHashSet<>(Arrays.asList(availableFonts)); + + log.info("========== Font Diagnostic Start =========="); + log.info("java.awt.headless={}", headless); + log.info("available font family count={}", availableFonts.length); + + for (String candidate : CANDIDATES) { + String matched = findMatched(candidate, fontSet); + if (matched == null) { + log.info("font candidate not found: {}", candidate); + continue; + } + + Font font = new Font(matched, Font.PLAIN, 18); + int unsupportedIndex = font.canDisplayUpTo(SAMPLE_TEXT); + + log.info("font candidate found: candidate={}, matchedFont={}, family={}, fontName={}, psName={}, canDisplayAll={}", + candidate, + matched, + font.getFamily(), + font.getFontName(), + font.getPSName(), + unsupportedIndex == -1); + + if (unsupportedIndex != -1) { + log.warn("font cannot fully display sample text: matchedFont={}, unsupportedIndex={}, sample={}", + matched, unsupportedIndex, SAMPLE_TEXT); + } + } + + log.info("========== Font Diagnostic End =========="); + } catch (Throwable e) { + log.error("字体诊断失败", e); + } + } + + private static String findMatched(String candidate, Set fontSet) { + for (String fontName : fontSet) { + if (fontName.equalsIgnoreCase(candidate)) { + return fontName; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/biutag/supervision/util/ChartRenderUtil.java b/src/main/java/com/biutag/supervision/util/ChartRenderUtil.java index 50cdcc5..c3ee9d4 100644 --- a/src/main/java/com/biutag/supervision/util/ChartRenderUtil.java +++ b/src/main/java/com/biutag/supervision/util/ChartRenderUtil.java @@ -3,6 +3,7 @@ package com.biutag.supervision.util; import org.jfree.chart.ChartFactory; import org.jfree.chart.ChartUtils; import org.jfree.chart.JFreeChart; +import org.jfree.chart.block.BlockBorder; import org.jfree.chart.labels.StandardCategoryItemLabelGenerator; import org.jfree.chart.labels.StandardPieSectionLabelGenerator; import org.jfree.chart.plot.CategoryPlot; @@ -12,15 +13,20 @@ import org.jfree.chart.renderer.category.BarRenderer; import org.jfree.chart.title.LegendTitle; import org.jfree.chart.ui.HorizontalAlignment; import org.jfree.chart.ui.RectangleEdge; +import org.jfree.chart.ui.RectangleInsets; import org.jfree.data.category.DefaultCategoryDataset; import org.jfree.data.general.DefaultPieDataset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; import java.awt.GraphicsEnvironment; import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.text.DecimalFormat; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -37,9 +43,16 @@ import java.util.Set; */ public class ChartRenderUtil { + private static final Logger log = LoggerFactory.getLogger(ChartRenderUtil.class); + private ChartRenderUtil() { } + /** + * 字体探测样本文本 + */ + private static final String FONT_PROBE_TEXT = "查实情况分布单位问题数量暂无数据"; + /** * 饼图配色 */ @@ -73,6 +86,21 @@ public class ChartRenderUtil { private static final String DEFAULT_OTHER_NAME = "其余问题类型"; private static final String DEFAULT_EMPTY_NAME = "暂无数据"; + /** + * 候选字体,按优先级排序 + */ + private static final String[] FONT_CANDIDATES = new String[]{ + "Microsoft YaHei", "微软雅黑", + "PingFang SC", "苹方-简", + "Noto Sans CJK SC", "Noto Sans SC", + "Source Han Sans CN", "Source Han Sans SC", + "WenQuanYi Micro Hei", "文泉驿微米黑", + "SimHei", "黑体", + "SimSun", "宋体", + "Arial Unicode MS", + "SansSerif" + }; + // ========================= // 对外 API:饼图(唯一入口) // ========================= @@ -94,14 +122,14 @@ public class ChartRenderUtil { chartData.forEach(dataset::setValue); JFreeChart chart = ChartFactory.createPieChart( - title, + safeTitle(title), dataset, true, false, false ); - beautifyPie(chart, chartData); + beautifyPie(chart, chartData, title); return toPng(chart, w, h, "生成饼图失败"); } @@ -123,12 +151,12 @@ public class ChartRenderUtil { DefaultCategoryDataset dataset = new DefaultCategoryDataset(); Map safeData = normalizeBarData(data); - safeData.forEach((category, value) -> dataset.addValue(value, yLabel, category)); + safeData.forEach((category, value) -> dataset.addValue(value, safeLabel(yLabel, "数量"), category)); JFreeChart chart = ChartFactory.createBarChart( - title, - xLabel, - yLabel, + safeTitle(title), + safeLabel(xLabel, "类别"), + safeLabel(yLabel, "数量"), dataset, PlotOrientation.VERTICAL, true, @@ -136,7 +164,7 @@ public class ChartRenderUtil { false ); - beautifyBar(chart, 1); + beautifyBar(chart, 1, title); return toPng(chart, w, h, "生成柱状图失败"); } @@ -176,9 +204,9 @@ public class ChartRenderUtil { } JFreeChart chart = ChartFactory.createBarChart( - title, - xLabel, - yLabel, + safeTitle(title), + safeLabel(xLabel, "类别"), + safeLabel(yLabel, "数量"), dataset, PlotOrientation.VERTICAL, true, @@ -186,7 +214,7 @@ public class ChartRenderUtil { false ); - beautifyBar(chart, safeSeriesData.size()); + beautifyBar(chart, safeSeriesData.size(), title); return toPng(chart, w, h, "生成分组柱状图失败"); } @@ -225,29 +253,31 @@ public class ChartRenderUtil { // 美化:饼图 // ========================= - private static void beautifyPie(JFreeChart chart, Map data) { + private static void beautifyPie(JFreeChart chart, Map data, String originalTitle) { chart.setAntiAlias(true); chart.setTextAntiAlias(true); chart.setBackgroundPaint(Color.WHITE); chart.setBorderVisible(false); + chart.setPadding(new RectangleInsets(8, 8, 8, 8)); - Font titleFont = pickFont(Font.BOLD, 18); - Font legendFont = pickFont(Font.PLAIN, 12); - Font labelFont = pickFont(Font.PLAIN, 11); + Font titleFont = pickFont(Font.BOLD, 18, safeTitle(originalTitle)); + Font legendFont = pickFont(Font.PLAIN, 12, "图例"); + Font labelFont = pickFont(Font.PLAIN, 11, FONT_PROBE_TEXT); if (chart.getTitle() != null) { chart.getTitle().setFont(titleFont); chart.getTitle().setPaint(new Color(51, 51, 51)); chart.getTitle().setHorizontalAlignment(HorizontalAlignment.CENTER); - chart.getTitle().setMargin(0, 0, 12, 0); + chart.getTitle().setMargin(8, 0, 14, 0); } LegendTitle legend = chart.getLegend(); if (legend != null) { legend.setItemFont(legendFont); legend.setBackgroundPaint(Color.WHITE); - legend.setBorder(0, 0, 0, 0); + legend.setFrame(BlockBorder.NONE); legend.setPosition(RectangleEdge.TOP); + legend.setMargin(0, 0, 8, 0); } PiePlot plot = (PiePlot) chart.getPlot(); @@ -290,29 +320,32 @@ public class ChartRenderUtil { // 美化:柱状图 // ========================= - private static void beautifyBar(JFreeChart chart, int seriesCount) { + private static void beautifyBar(JFreeChart chart, int seriesCount, String originalTitle) { chart.setAntiAlias(true); chart.setTextAntiAlias(true); chart.setBackgroundPaint(Color.WHITE); chart.setBorderVisible(false); + chart.setPadding(new RectangleInsets(8, 8, 8, 8)); - Font titleFont = pickFont(Font.BOLD, 16); - Font legendFont = pickFont(Font.PLAIN, 12); - Font axisFont = pickFont(Font.PLAIN, 12); - Font valueFont = pickFont(Font.PLAIN, 11); + Font titleFont = pickFont(Font.BOLD, 16, safeTitle(originalTitle)); + Font legendFont = pickFont(Font.PLAIN, 12, "图例"); + Font axisFont = pickFont(Font.PLAIN, 12, FONT_PROBE_TEXT); + Font valueFont = pickFont(Font.PLAIN, 11, "100"); if (chart.getTitle() != null) { chart.getTitle().setFont(titleFont); chart.getTitle().setPaint(new Color(51, 51, 51)); chart.getTitle().setHorizontalAlignment(HorizontalAlignment.CENTER); + chart.getTitle().setMargin(8, 0, 12, 0); } LegendTitle legend = chart.getLegend(); if (legend != null) { legend.setItemFont(legendFont); legend.setBackgroundPaint(Color.WHITE); - legend.setBorder(0, 0, 0, 0); + legend.setFrame(BlockBorder.NONE); legend.setPosition(RectangleEdge.TOP); + legend.setMargin(0, 0, 8, 0); } CategoryPlot plot = chart.getCategoryPlot(); @@ -320,6 +353,7 @@ public class ChartRenderUtil { plot.setOutlineVisible(false); plot.setRangeGridlinePaint(new Color(230, 230, 230)); plot.setRangeGridlinesVisible(true); + plot.setInsets(new RectangleInsets(4, 8, 4, 8)); plot.getDomainAxis().setTickLabelFont(axisFont); plot.getDomainAxis().setLabelFont(axisFont); @@ -331,7 +365,6 @@ public class ChartRenderUtil { renderer.setShadowVisible(false); renderer.setItemMargin(0.10); - // 多系列时适当放宽柱宽 if (seriesCount <= 1) { renderer.setMaximumBarWidth(0.12); } else if (seriesCount == 2) { @@ -528,35 +561,105 @@ public class ChartRenderUtil { } // ========================= - // 字体兜底(避免 Linux 没微软雅黑导致中文丑/方块) + // 字体处理 // ========================= - private static Font pickFont(int style, int size) { - try { - // 按优先级尝试(Windows 常见:微软雅黑;Linux 常见:Noto/文泉驿/DejaVu) - String[] candidates = new String[]{ - "Microsoft YaHei", "微软雅黑", - "PingFang SC", "苹方-简", - "Noto Sans CJK SC", "Noto Sans SC", - "WenQuanYi Micro Hei", "文泉驿微米黑", - "SimSun", "宋体", - "Arial Unicode MS", - "SansSerif" - }; + /** + * 从 classpath 注册字体(可选) + * 例如:/fonts/NotoSansCJKsc-Regular.otf + */ + public static void registerFontFromResource(String classpathLocation) { + if (classpathLocation == null || classpathLocation.isBlank()) { + return; + } + + try (InputStream inputStream = ChartRenderUtil.class.getResourceAsStream(classpathLocation.trim())) { + if (inputStream == null) { + log.warn("字体资源未找到: {}", classpathLocation); + return; + } + Font font = Font.createFont(Font.TRUETYPE_FONT, inputStream); + GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); + boolean registered = ge.registerFont(font); + log.info("注册字体完成, location={}, fontName={}, family={}, registered={}", + classpathLocation, font.getFontName(), font.getFamily(), registered); + } catch (Exception e) { + log.warn("注册字体失败, location={}", classpathLocation, e); + } + } + /** + * 更稳妥的字体选择: + * 1. 优先按候选名单查找 + * 2. 必须校验 canDisplayUpTo(sampleText) == -1 + * 3. 最后退回 SansSerif + */ + private static Font pickFont(int style, int size, String sampleText) { + String probe = safeProbeText(sampleText); + try { GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); String[] available = ge.getAvailableFontFamilyNames(); - for (String c : candidates) { - for (String a : available) { - if (a.equalsIgnoreCase(c)) { - return new Font(a, style, size); - } + Set availableSet = new LinkedHashSet<>(Arrays.asList(available)); + + for (String candidate : FONT_CANDIDATES) { + String matchedFont = findMatchedFont(candidate, availableSet); + if (matchedFont == null) { + continue; + } + + Font font = new Font(matchedFont, style, size); + int unsupportedIndex = font.canDisplayUpTo(probe); + if (unsupportedIndex == -1) { + log.debug("选择字体成功, candidate={}, matchedFont={}, style={}, size={}, probe={}", + candidate, matchedFont, style, size, probe); + return font; + } else { + log.warn("字体存在缺字, candidate={}, matchedFont={}, unsupportedIndex={}, probe={}", + candidate, matchedFont, unsupportedIndex, probe); } } } catch (Throwable e) { - // 字体系统初始化失败时兜底 - System.err.println("Font detection failed, fallback to SansSerif: " + e.getMessage()); + log.warn("字体检测失败,使用 SansSerif 兜底", e); + } + + Font fallback = new Font("SansSerif", style, size); + int unsupportedIndex = fallback.canDisplayUpTo(probe); + if (unsupportedIndex != -1) { + log.warn("最终兜底字体 SansSerif 仍无法完整显示文本, unsupportedIndex={}, probe={}", + unsupportedIndex, probe); + } else { + log.debug("使用兜底字体 SansSerif 成功, style={}, size={}, probe={}", style, size, probe); + } + return fallback; + } + + private static String findMatchedFont(String candidate, Set availableSet) { + for (String fontName : availableSet) { + if (fontName.equalsIgnoreCase(candidate)) { + return fontName; + } + } + return null; + } + + private static String safeProbeText(String text) { + if (text == null || text.isBlank()) { + return FONT_PROBE_TEXT; + } + return text.trim(); + } + + private static String safeTitle(String title) { + if (title == null || title.isBlank()) { + return DEFAULT_EMPTY_NAME; + } + return title.trim(); + } + + private static String safeLabel(String label, String defaultValue) { + if (label == null || label.isBlank()) { + return defaultValue; } - return new Font("SansSerif", style, size); + return label.trim(); } } \ No newline at end of file diff --git a/src/main/resources/fonts/NotoSansCJKsc-Bold.otf b/src/main/resources/fonts/NotoSansCJKsc-Bold.otf new file mode 100644 index 0000000..ff4c045 Binary files /dev/null and b/src/main/resources/fonts/NotoSansCJKsc-Bold.otf differ diff --git a/src/main/resources/fonts/NotoSansCJKsc-Regular.otf b/src/main/resources/fonts/NotoSansCJKsc-Regular.otf new file mode 100644 index 0000000..dc15562 Binary files /dev/null and b/src/main/resources/fonts/NotoSansCJKsc-Regular.otf differ