fix(diagram): 流程图汉字方框——CJK 字体探测链(物理字体canDisplay验证→SansSerif回退→font-path配置兜底) + 副标起笔与label重叠
build / build-push (push) Successful in 2m18s
build / build-push (push) Successful in 2m18s
This commit is contained in:
@@ -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<String> 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 行;溢出行以 … 结尾 */
|
||||
|
||||
@@ -68,6 +68,20 @@ class DiagramImageServiceTest {
|
||||
assertNotEquals(0, service.renderPng(vo(nodes, edges)).length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void smoke_writeSamplePng_forVisualCheck() throws Exception {
|
||||
List<FlowModels.NodeVo> 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<FlowModels.EdgeVo> 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 场景防御)
|
||||
|
||||
Reference in New Issue
Block a user