From bfa2dfc713bfc6c665997c8c91d8df26d1ccc633 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 17 Sep 2026 08:22:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(diagram):=20=E6=B5=81=E7=A8=8B=E5=9B=BE?= =?UTF-8?q?=E6=B1=89=E5=AD=97=E6=96=B9=E6=A1=86=E2=80=94=E2=80=94CJK=20?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E6=8E=A2=E6=B5=8B=E9=93=BE(=E7=89=A9?= =?UTF-8?q?=E7=90=86=E5=AD=97=E4=BD=93canDisplay=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E2=86=92SansSerif=E5=9B=9E=E9=80=80=E2=86=92font-path=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=9C=E5=BA=95)=20+=20=E5=89=AF=E6=A0=87=E8=B5=B7?= =?UTF-8?q?=E7=AC=94=E4=B8=8Elabel=E9=87=8D=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DiagramImageService.java | 83 ++++++++++++++++++- .../service/DiagramImageServiceTest.java | 14 ++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java index da0e02b..2d1eb66 100644 --- a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java @@ -2,6 +2,7 @@ package com.yunhai.flowcenter.service; import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.model.FlowModels; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; @@ -12,12 +13,14 @@ import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.geom.Path2D; +import java.awt.GraphicsEnvironment; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Arrays; import java.util.Map; /** @@ -31,6 +34,7 @@ import java.util.Map; * 节点颜色按运行时状态 done/active/rejected/pending 上色,图例仅 instance 口径绘制。 * 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。 */ +@Slf4j @Service public class DiagramImageService { @@ -243,13 +247,15 @@ public class DiagramImageService { FontMetrics lf = g.getFontMetrics(labelF); g.setFont(labelF); g.setColor(C_TEXT); - g.drawString(truncate(g, n.getLabel(), NODE_W - 20), p[0] + 10, p[1] + 12 + lf.getAscent()); + String label = truncate(g, n.getLabel(), NODE_W - 20); + g.drawString(label, p[0] + 10, p[1] + 12 + lf.getAscent()); if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) { g.setFont(subF); g.setColor(C_SUB); FontMetrics sf = g.getFontMetrics(subF); List lines = wrap(g, n.getSubLabel(), NODE_W - 20, 2); - int firstLineY = p[1] + 12 + lf.getHeight(); + // 副标从 label 基线下方一整行处起步,避免与 label 重叠 + int firstLineY = p[1] + 12 + lf.getAscent() + sf.getAscent(); for (int i = 0; i < lines.size(); i++) { g.drawString(lines.get(i), p[0] + 10, firstLineY + i * sf.getHeight()); } @@ -329,8 +335,77 @@ public class DiagramImageService { } - private static Font cjkFont(int style, int size) { - return new Font("Microsoft YaHei", style, size); + /** + * 可选指定字体文件(TTF/OTF,支持 .ttc 首个 face):容器/精简 OS 无 CJK 系统字体时的终极兜底, + * 配置路径后 createFont 注册为程序字体并优先命中。空(缺省)走系统字体探测。 + */ + @org.springframework.beans.factory.annotation.Value("${flow-center.diagram.font-path:}") + private String fontPathConfig; + + /** 候选族名(探测顺序:Windows/macOS 常见 → Linux 常见发行字体;依次用 canDisplay 验证真含字形) */ + private static final String[] CJK_FAMILY_PREFS = { + "Microsoft YaHei", "微软雅黑", "SimHei", "NSimSun", "PingFang SC", + "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", + }; + + private static volatile Font CJK_BASE; + + /** 样式派生统一走这里保证全图同族(宽度换行/截断的 FontMetrics 与绘制必须一致) */ + private Font cjkFont(int style, int size) { + return baseFont().deriveFont(style, (float) size); + } + + /** + * CJK 基准字体(惰性单例):新物理字体名在含 CJK 字形时命中;否则回退逻辑族 SansSerif + * (JRE fontconfig 正常时自动复合映射系统 CJK)。全部失败时仍返回 SansSerif—— + * 该状态说明宿主无任何 CJK 字体,需要配 flow-center.diagram.font-path(告警打在 resolve 里)。 + */ + private Font baseFont() { + Font base = CJK_BASE; + if (base != null) { + return base; + } + synchronized (DiagramImageService.class) { + if (CJK_BASE == null) { + CJK_BASE = resolveCJK(); + } + return CJK_BASE; + } + } + + private Font resolveCJK() { + // 配置文件路径最优先(容器部署兜底) + GraphicsEnvironment env = GraphicsEnvironment.getLocalGraphicsEnvironment(); + if (fontPathConfig != null && !fontPathConfig.trim().isEmpty()) { + try (java.io.InputStream in = new java.io.FileInputStream(fontPathConfig.trim())) { + Font loaded = Font.createFont(Font.TRUETYPE_FONT, in); + env.registerFont(loaded); + log.info("[flow-center] 流程图字体已加载配置文件: {} → {}", fontPathConfig, loaded.getFontName()); + return loaded; + } catch (Exception e) { + log.error("[flow-center] flow-center.diagram.font-path 加载失败: {}({}),改走系统探测", fontPathConfig, e.getMessage()); + } + } + String sample = "教师入职审批·任一人通过时限"; + for (String family : CJK_FAMILY_PREFS) { + Font f = new Font(family, Font.PLAIN, 14); + // 命中同名物理字体且真含 CJK 字形才算(未命中时 new Font 返回 Dialog 映射、canDisplay 成立也不能认) + if (Arrays.asList(env.getAvailableFontFamilyNames()).contains(family) + && f.canDisplayUpTo(sample) == -1) { + return f; + } + } + Font fallback = tofuWarning(new Font(Font.SANS_SERIF, Font.PLAIN, 14), sample); + if (fallback != null) { + return fallback; + } + log.error("[flow-center] 找不到含中文的字体(流程图汉字将显示为方框):请在容器镜像安装字体" + + "(如 fonts-noto-cjk),或配置 flow-center.diagram.font-path 指向 TTF 文件"); + return new Font(Font.SANS_SERIF, Font.PLAIN, 14); + } + + private Font tofuWarning(Font f, String sample) { + return f.canDisplayUpTo(sample) == -1 ? f : null; } /** 贪心按像素宽度折行,最多 maxLines 行;溢出行以 … 结尾 */ diff --git a/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java index ba75a7f..1a2a16d 100644 --- a/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java @@ -68,6 +68,20 @@ class DiagramImageServiceTest { assertNotEquals(0, service.renderPng(vo(nodes, edges)).length); } + @Test + void smoke_writeSamplePng_forVisualCheck() throws Exception { + List nodes = new ArrayList<>(Arrays.asList( + node("n1", "START", "开始", null, null), + node("n2", "APPROVAL", "教师入职审批", "刘文娟 · 时限48h", "done"), + node("n3", "APPROVAL", "科室审批", "教学处 · 云海小学001 · 任一人通过 · 时限24h", "active"), + node("n4", "END", "结束", null, null))); + List edges = new ArrayList<>(Arrays.asList( + edge("n1", "n2", null), edge("n2", "n3", "同意"), edge("n3", "n4", null))); + java.nio.file.Files.write( + java.nio.file.Paths.get("target", "diagram-smoke.png"), + service.renderPng(vo(nodes, edges))); + } + @Test void render_defensive_missingEndpointAndCycle_stillProducesImage() { // 成环 + 指向不存在端点的边:不抛,仍出图(外部 img 场景防御)