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 字节流。 *

* 背景(用户拍板 2026-09-16):外部系统要像嵌 CAS 验证码一样用 {@code } 直接嵌流程图, * 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的 * 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。 *

* 布局(2026-09-17 重构,与前端 flowLayout 同口径):自左向右拓扑分层(最长路径),并行分支组以 * SPLIT 中心线为轴垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN),忽略设计器 * 画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(右中→中线→左中)。 * 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态 * done/active/rejected/pending 上色,图例仅 instance 口径绘制。 * 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。 */ @Slf4j @Service public class DiagramImageService { /** 逻辑坐标常量(输出按 SCALE 倍导出,保证 缩放清晰) */ 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 nodes = vo.getNodes() == null ? new ArrayList<>() : vo.getNodes(); List edges = vo.getEdges() == null ? new ArrayList<>() : vo.getEdges(); Map byId = new HashMap<>(); for (FlowModels.NodeVo n : nodes) { byId.put(n.getId(), n); } Map> out = new HashMap<>(); Map 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 level = new HashMap<>(); List queue = new ArrayList<>(); for (Map.Entry 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> 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 layer : layers.values()) { maxRows = Math.max(maxRows, layer.size()); } int axisH = maxRows * ROW_H; Map box = new HashMap<>(); for (Map.Entry> en : layers.entrySet()) { List 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> 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 nodes, Map> out, Map box) { class Group { final String split; final String join; final List members; final int maxX; Group(String split, String join, List members, int maxX) { this.split = split; this.join = join; this.members = members; this.maxX = maxX; } } List 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 order = new HashMap<>(); order.put(sid, 0); List 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 heads = out.getOrDefault(sid, new ArrayList<>()); List> reachSets = new ArrayList<>(); for (String head : heads) { java.util.Set set = new java.util.HashSet<>(); List 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 common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0); for (java.util.Set 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 members = new ArrayList<>(); java.util.Set seen = new java.util.HashSet<>(); seen.add(join); List 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 + "…"; } }