Files
flow-center-service/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java
T
chenxing 9cc0b06c29
build / build-push (push) Successful in 1m30s
feat(flow): 工作台与轨迹优化 + 管理端启用回路 + 本地验签配置修复
- 已办去重: 同一办件一行 + doneCount 累计次数(FlwTaskRepository 查询去重)
- 发起/撤回/重提留痕事件行(状态码 6/7, 无需 DDL); /oapi/flow/progress steps 含撤回/重提步骤
- 管理端新增启用回路: POST /admin/map/enable/{id}、POST /admin/def/enable/{id}; GET /api/flow/def/source 源码查询
- 修复 findEnabledByOrdId 未过滤 status 的隐性缺陷; 新增 DeptLabelSupport 科室标签支撑
- 测试: FlowEngineIT 撤回留痕/已办去重断言、OapiFlowContractIT 撤回步骤断言(94 tests 全过)
- 本地验签修复: 本地 UserBase 无 context-path, yh.open.services 显式覆盖服务目录枚举兜底的 /userbase basePath, 否则出站签名与服务端剥离不对齐 → GetUserInfo 等全部「验签失败」; 生产经 Caddy 挂 /userbase 时删除 services 覆盖
2026-09-18 16:56:01 +08:00

593 lines
26 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
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.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 流程示意图服务端出图(Java2D,无外部渲染依赖):DiagramVo → PNG 字节流。
* <p>
* 背景(用户拍板 2026-09-16):外部系统要像嵌 CAS 验证码一样用 {@code <img src>} 直接嵌流程图,
* 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的
* 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。
* <p>
* 布局(2026-09-17 重构,与前端 flowLayout 同口径):自左向右拓扑分层(最长路径),并行分支组以
* SPLIT 中心线为轴垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN),忽略设计器
* 画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(右中→中线→左中)。
* 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态
* done/active/rejected/pending 上色,图例仅 instance 口径绘制。
* 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。
*/
@Slf4j
@Service
public class DiagramImageService {
/** 逻辑坐标常量(输出按 SCALE 倍导出,保证 <img> 缩放清晰) */
private static final int SCALE = 2;
private static final int MARGIN = 36;
private static final int TITLE_H = 56;
private static final int SLOT_W = 210; // 列槽宽(盒型在槽内水平居中)
private static final int GAP_X = 84; // 列间距(留给正交折线走线)
private static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中)
private static final int LEGEND_H = 48;
private static final int ARROW = 9;
/** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行菱形(取外接矩形) */
private static int[] boxOf(String type) {
switch (type == null ? "" : type) {
case FlowDefModel.TYPE_START:
case FlowDefModel.TYPE_END:
return new int[]{116, 44};
case FlowDefModel.TYPE_PARALLEL_SPLIT:
case FlowDefModel.TYPE_PARALLEL_JOIN:
return new int[]{132, 78};
default:
return new int[]{200, 64};
}
}
// —— 配色与前端 FlowDiagram.vue 示意图一致(深色线/高对比文字) ——
private static final Color C_TITLE = new Color(0x17, 0x23, 0x3D);
private static final Color C_LINE = new Color(0x51, 0x5A, 0x6E);
private static final Color C_EDGE_LABEL = new Color(0x51, 0x5A, 0x6E);
private static final Color C_TEXT = new Color(0x17, 0x23, 0x3D);
private static final Color C_SUB = new Color(0x80, 0x86, 0x95);
private static final Color C_APPROVAL_BORDER = new Color(0x2D, 0x8C, 0xF0);
private static final Color C_START_FILL = new Color(0x19, 0xBE, 0x6B);
private static final Color C_END_FILL = new Color(0x80, 0x86, 0x95);
private static final Color C_PARALLEL_FILL = new Color(0xFF, 0xF8, 0xF0);
private static final Color C_PARALLEL_BORDER = new Color(0xFF, 0x99, 0x00);
private static final Color C_PARALLEL_TEXT = new Color(0xE4, 0x6A, 0x0A);
private static final Color C_DONE_BORDER = new Color(0x19, 0xBE, 0x6B);
private static final Color C_ACTIVE_BORDER = new Color(0xED, 0x40, 0x14);
private static final Color C_REJECT_FILL = new Color(0xFF, 0xF1, 0xF0);
private static final Color C_REJECT_BORDER = new Color(0xED, 0x40, 0x14);
private static final Color C_PENDING_BORDER = new Color(0xDC, 0xDE, 0xE2);
/** 出图主入口;instance 口径节点带 state 时自动着色并画图例 */
public byte[] renderPng(FlowModels.DiagramVo vo) {
BufferedImage img = draw(vo);
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ImageIO.write(img, "png", out);
return out.toByteArray();
} catch (Exception e) {
throw new IllegalStateException("流程图渲染失败:" + e.getMessage(), e);
}
}
private BufferedImage draw(FlowModels.DiagramVo vo) {
List<FlowModels.NodeVo> nodes = vo.getNodes() == null ? new ArrayList<>() : vo.getNodes();
List<FlowModels.EdgeVo> edges = vo.getEdges() == null ? new ArrayList<>() : vo.getEdges();
Map<String, FlowModels.NodeVo> byId = new HashMap<>();
for (FlowModels.NodeVo n : nodes) {
byId.put(n.getId(), n);
}
Map<String, List<String>> out = new HashMap<>();
Map<String, Integer> inDeg = new LinkedHashMap<>();
for (FlowModels.NodeVo n : nodes) {
inDeg.put(n.getId(), 0);
}
for (FlowModels.EdgeVo e : edges) {
if (byId.containsKey(e.getFrom()) && byId.containsKey(e.getTo())) {
out.computeIfAbsent(e.getFrom(), k -> new ArrayList<>()).add(e.getTo());
inDeg.merge(e.getTo(), 1, Integer::sum);
}
}
// 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
Map<String, Integer> level = new HashMap<>();
List<String> queue = new ArrayList<>();
for (Map.Entry<String, Integer> en : inDeg.entrySet()) {
if (en.getValue() == 0) {
level.put(en.getKey(), 0);
queue.add(en.getKey());
}
}
while (!queue.isEmpty()) {
String id = queue.remove(queue.size() - 1);
int parent = level.get(id);
for (String to : out.getOrDefault(id, new ArrayList<>())) {
level.merge(to, parent + 1, Math::max);
int d = inDeg.merge(to, -1, Integer::sum);
if (d == 0) {
queue.add(to);
}
}
}
for (FlowModels.NodeVo n : nodes) {
level.putIfAbsent(n.getId(), 0);
}
// 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
int maxLevel = 0;
int maxRows = 1;
for (FlowModels.NodeVo n : nodes) {
int l = level.get(n.getId());
maxLevel = Math.max(maxLevel, l);
layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n);
}
for (List<FlowModels.NodeVo> layer : layers.values()) {
maxRows = Math.max(maxRows, layer.size());
}
int axisH = maxRows * ROW_H;
Map<String, int[]> box = new HashMap<>();
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
List<FlowModels.NodeVo> layer = en.getValue();
int blockH = layer.size() * ROW_H;
int top0 = (axisH - blockH) / 2;
for (int i = 0; i < layer.size(); i++) {
FlowModels.NodeVo n = layer.get(i);
int[] wh = boxOf(n.getType());
box.put(n.getId(), new int[]{
en.getKey() * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2,
top0 + i * ROW_H + (ROW_H - wh[1]) / 2,
wh[0], wh[1]});
}
}
// 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0)
centerParallelGroups(nodes, out, box);
// 依据实际包围盒定画布尺寸并整体平移进留白
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
for (int[] b : box.values()) {
minX = Math.min(minX, b[0]);
minY = Math.min(minY, b[1]);
maxX = Math.max(maxX, b[0] + b[2]);
maxY = Math.max(maxY, b[1] + b[3]);
}
boolean hasState = false;
for (FlowModels.NodeVo n : nodes) {
if (n.getState() != null) {
hasState = true;
break;
}
}
int dx = MARGIN - minX;
int dy = MARGIN + TITLE_H - minY;
for (int[] b : box.values()) {
b[0] += dx;
b[1] += dy;
}
int imgW = (maxX - minX) + MARGIN * 2;
int imgH = TITLE_H + (maxY - minY) + MARGIN * 2 + (hasState ? LEGEND_H : 0);
BufferedImage canvas = new BufferedImage(imgW * SCALE, imgH * SCALE, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = canvas.createGraphics();
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.scale(SCALE, SCALE);
g.setColor(Color.WHITE);
g.fillRect(0, 0, imgW, imgH);
Font labelFont = cjkFont(Font.BOLD, 14);
Font subFont = cjkFont(Font.PLAIN, 12);
Font titleFont = cjkFont(Font.BOLD, 17);
Font smallFont = cjkFont(Font.PLAIN, 12);
g.setFont(titleFont);
g.setColor(C_TITLE);
String title = vo.getName() == null || vo.getName().isEmpty() ? "流程示意图" : vo.getName();
FontMetrics titleFm = g.getFontMetrics();
g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 2);
// 连线(先画线后画节点,节点底色盖住线头):正交折线 右中→中线→左中
Map<String, Map<String, String>> edgeNames = new HashMap<>();
for (FlowModels.EdgeVo e : edges) {
edgeNames.computeIfAbsent(e.getFrom(), k -> new HashMap<>()).put(e.getTo(), e.getName());
}
g.setStroke(new BasicStroke(1.6f));
for (FlowModels.EdgeVo e : edges) {
int[] from = box.get(e.getFrom());
int[] to = box.get(e.getTo());
if (from == null || to == null) {
continue;
}
int x1 = from[0] + from[2];
int y1 = from[1] + from[3] / 2;
int x2 = to[0];
int y2 = to[1] + to[3] / 2;
int mid = (x1 + x2) / 2;
g.setColor(C_LINE);
g.drawLine(x1, y1, mid, y1);
g.drawLine(mid, y1, mid, y2);
g.drawLine(mid, y2, x2 - ARROW, y2);
drawArrowHead(g, x2 - ARROW, y2);
// 多出口分支文案:折线竖段中点白底小字(单出口连线不标,避免噪音)
if (edgeNames.get(e.getFrom()).size() > 1 && e.getName() != null && !e.getName().isEmpty()) {
drawMidLabel(g, mid, (y1 + y2) / 2, e.getName(), smallFont);
}
}
for (FlowModels.NodeVo n : nodes) {
drawNode(g, n, box.get(n.getId()), labelFont, subFont);
}
// 图例(仅 instance 口径)
if (hasState) {
g.setFont(smallFont);
FontMetrics sFm = g.getFontMetrics();
int ly = imgH - LEGEND_H + 20;
int x = MARGIN;
String[][] legend = {{"active", "在办"}, {"done", "已办"}, {"pending", "未到"}, {"rejected", "被拒绝"}};
for (String[] it : legend) {
g.setColor(Color.WHITE);
g.fillRoundRect(x, ly - 10, 18, 12, 6, 6);
g.setColor(borderOf(it[0]));
g.setStroke(new BasicStroke(1.4f));
g.drawRoundRect(x, ly - 10, 18, 12, 6, 6);
g.setColor(C_TEXT);
g.drawString(it[1], x + 24, ly + 2);
x += 24 + sFm.stringWidth(it[1]) + 24;
}
}
g.dispose();
return canvas;
}
/**
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。
* SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN);
* 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。
*/
private void centerParallelGroups(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
Map<String, int[]> box) {
class Group {
final String split;
final String join;
final List<String> members;
final int maxX;
Group(String split, String join, List<String> members, int maxX) {
this.split = split;
this.join = join;
this.members = members;
this.maxX = maxX;
}
}
List<Group> groups = new ArrayList<>();
for (FlowModels.NodeVo split : nodes) {
if (!FlowDefModel.TYPE_PARALLEL_SPLIT.equals(split.getType()) || !box.containsKey(split.getId())) {
continue;
}
String sid = split.getId();
// BFS 序 + 各分支可达集,用于定位配对 JOIN
Map<String, Integer> order = new HashMap<>();
order.put(sid, 0);
List<String> bfs = new ArrayList<>();
bfs.add(sid);
while (!bfs.isEmpty()) {
String cur = bfs.remove(0);
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
if (!order.containsKey(to)) {
order.put(to, order.size());
bfs.add(to);
}
}
}
List<String> heads = out.getOrDefault(sid, new ArrayList<>());
List<java.util.Set<String>> reachSets = new ArrayList<>();
for (String head : heads) {
java.util.Set<String> set = new java.util.HashSet<>();
List<String> q = new ArrayList<>();
q.add(head);
while (!q.isEmpty()) {
String cur = q.remove(0);
if (set.contains(cur)) {
continue;
}
set.add(cur);
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
}
reachSets.add(set);
}
java.util.Set<String> common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0);
for (java.util.Set<String> set : reachSets) {
common = new java.util.HashSet<>(common);
common.retainAll(set);
}
String join = null;
int joinOrder = Integer.MAX_VALUE;
for (FlowModels.NodeVo n : nodes) {
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.getType()) && box.containsKey(n.getId())
&& common.contains(n.getId())) {
int o = order.getOrDefault(n.getId(), Integer.MAX_VALUE);
if (o < joinOrder) {
joinOrder = o;
join = n.getId();
}
}
}
if (join == null) {
continue;
}
// 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN)
List<String> members = new ArrayList<>();
java.util.Set<String> seen = new java.util.HashSet<>();
seen.add(join);
List<String> q = new ArrayList<>();
q.add(sid);
while (!q.isEmpty()) {
String cur = q.remove(0);
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
if (seen.contains(to) || !box.containsKey(to)) {
continue;
}
seen.add(to);
members.add(to);
q.add(to);
}
}
int maxLevel = 0;
for (String m : members) {
maxLevel = Math.max(maxLevel, box.get(m)[0]);
}
groups.add(new Group(sid, join, members, maxLevel));
}
// maxX 降序 = 内层组先处理
groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX));
for (Group gp : groups) {
int[] s = box.get(gp.split);
int axisY = s[1] + s[3] / 2;
int top = Integer.MAX_VALUE;
int bottom = Integer.MIN_VALUE;
for (String m : gp.members) {
int[] b = box.get(m);
top = Math.min(top, b[1]);
bottom = Math.max(bottom, b[1] + b[3]);
}
int delta = axisY - (top + bottom) / 2;
for (String m : gp.members) {
box.get(m)[1] += delta;
}
int[] j = box.get(gp.join);
j[1] += axisY - (j[1] + j[3] / 2);
}
// 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中)
int minY = Integer.MAX_VALUE;
for (int[] b : box.values()) {
minY = Math.min(minY, b[1]);
}
if (minY < 0) {
for (int[] b : box.values()) {
b[1] -= minY;
}
}
}
private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) {
String type = n.getType() == null ? "" : n.getType();
if (FlowDefModel.TYPE_START.equals(type) || FlowDefModel.TYPE_END.equals(type)) {
// 开始/结束:实心胶囊(绿/灰)+ 白字
g.setColor(FlowDefModel.TYPE_START.equals(type) ? C_START_FILL : C_END_FILL);
g.fillRoundRect(p[0], p[1], p[2], p[3], p[3], p[3]);
g.setFont(labelF);
g.setColor(Color.WHITE);
FontMetrics fm = g.getFontMetrics();
String label = truncate(g, n.getLabel(), p[2] - 24);
g.drawString(label, p[0] + (p[2] - fm.stringWidth(label)) / 2f,
p[1] + (p[3] - fm.getHeight()) / 2 + fm.getAscent());
return;
}
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(type) || FlowDefModel.TYPE_PARALLEL_JOIN.equals(type)) {
// 并行分发/汇聚等待:菱形(橙框橙字,active 红框)
int cx = p[0] + p[2] / 2;
int cy = p[1] + p[3] / 2;
Path2D d = new Path2D.Float();
d.moveTo(cx, p[1]);
d.lineTo(p[0] + p[2], cy);
d.lineTo(cx, p[1] + p[3]);
d.lineTo(p[0], cy);
d.closePath();
g.setColor(C_PARALLEL_FILL);
g.fill(d);
g.setColor("active".equals(n.getState()) ? C_ACTIVE_BORDER : C_PARALLEL_BORDER);
g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2.2f : 1.6f));
g.draw(d);
g.setFont(subF);
g.setColor(C_PARALLEL_TEXT);
FontMetrics fm = g.getFontMetrics();
String label = truncate(g, n.getLabel(), p[2] - 36);
g.drawString(label, cx - fm.stringWidth(label) / 2f, cy + fm.getAscent() / 2f - 1);
return;
}
// 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),label + 单行 subLabel
g.setColor("rejected".equals(n.getState()) ? C_REJECT_FILL : Color.WHITE);
g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12);
boolean active = "active".equals(n.getState());
g.setColor(borderOf(n));
g.setStroke(new BasicStroke(active ? 2.4f : 1.6f));
g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12);
g.setFont(labelF);
g.setColor(C_TEXT);
String label = truncate(g, n.getLabel(), p[2] - 20);
g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26);
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
g.setFont(subF);
g.setColor(C_SUB);
String sub = truncate(g, n.getSubLabel(), p[2] - 20);
g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46);
}
}
private void drawMidLabel(Graphics2D g, int midX, int midY, String s, Font font) {
g.setFont(font);
FontMetrics fm = g.getFontMetrics();
int sw = fm.stringWidth(s);
g.setColor(Color.WHITE);
g.fillRect(midX - sw / 2 - 4, midY - fm.getAscent() / 2 - 3, sw + 8, fm.getHeight() + 2);
g.setColor(C_EDGE_LABEL);
g.drawString(s, midX - sw / 2f, midY + fm.getAscent() / 2f - 1);
}
/** 箭头指向 +x(分层自左向右,目标恒在右侧) */
private void drawArrowHead(Graphics2D g, int x, int y) {
Path2D p = new Path2D.Float();
p.moveTo(x + ARROW, y);
p.lineTo(x, y - ARROW / 2.0 + 1);
p.lineTo(x, y + ARROW / 2.0 - 1);
p.closePath();
g.setColor(C_LINE);
g.fill(p);
}
private Color borderOf(FlowModels.NodeVo n) {
String s = n.getState();
if ("active".equals(s)) {
return C_ACTIVE_BORDER;
}
if ("done".equals(s)) {
return C_DONE_BORDER;
}
if ("rejected".equals(s)) {
return C_REJECT_BORDER;
}
if ("pending".equals(s)) {
return C_PENDING_BORDER;
}
String t = n.getType() == null ? "" : n.getType();
if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) {
return C_PARALLEL_BORDER;
}
return C_APPROVAL_BORDER;
}
private Color borderOf(String state) {
switch (state) {
case "active": return C_ACTIVE_BORDER;
case "done": return C_DONE_BORDER;
case "rejected": return C_REJECT_BORDER;
default: return C_PENDING_BORDER;
}
}
/**
* 可选指定字体文件(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;
}
private static String truncate(Graphics2D g, String s, int width) {
if (s == null || s.isEmpty()) {
return "";
}
FontMetrics fm = g.getFontMetrics();
if (fm.stringWidth(s) <= width) {
return s;
}
while (s.length() > 1 && fm.stringWidth(s + "…") > width) {
s = s.substring(0, s.length() - 1);
}
return s + "…";
}
}