云
4 天以前 0f11bea64e963b2231875760bd4e9103e99b8a63
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java
@@ -17,10 +17,7 @@
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -28,8 +25,7 @@
/**
 * 二维码标签图片工具类
 * <p>
 * 生成"二维码 + 右侧可读文本"的合成图片:二维码只编码纯码值,文本叠加关键信息,
 * 即使数据库丢失也能从打印图片还原关键信息。
 * 生成二维码在上、码值在下的紧凑标签。二维码内容仍使用调用方传入的溯源链接。
 */
@Slf4j
public final class QrCodeImageUtils {
@@ -45,61 +41,65 @@
    /** 图片四周留白 */
    private static final int PADDING = 16;
    /** 二维码与文本间距 */
    private static final int GAP = 24;
    /** 文本区域宽度 */
    private static final int TEXT_WIDTH = 380;
    private static final int GAP = 4;
    /** 字体大小 */
    private static final int FONT_SIZE = 15;
    private static final int FONT_SIZE = 24;
    /** 行高 */
    private static final int LINE_HEIGHT = 24;
    /** 字段间距 */
    private static final int FIELD_GAP = 6;
    private static final int LINE_HEIGHT = 32;
    /** 中文字体候选(按优先级),避免 Linux headless 下文字变方框 */
    /**
     * 中文字体候选(按优先级)。
     * <p>
     * 只允许收录可渲染中文的字体:纯拉丁字体(如 Arial)一旦入选,中文会全部渲染成方框,
     * 且导出过程不报错,属于最隐蔽的一类事故。
     */
    private static final String[] FONT_CANDIDATES = {
            "Noto Sans CJK SC", "WenQuanYi Zen Hei", "Microsoft YaHei", "SimSun", "Arial"
            "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Zen Hei", "PingFang SC",
            "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", "SimSun"
    };
    /** 中文字形探针字符(用于探测字体是否具备中文字形) */
    private static final char CJK_PROBE = '袋';
    /** 解析出的中文字体族名;为 null 表示当前环境没有任何可渲染中文的字体 */
    private static final String FONT_NAME = resolveFontName();
    /**
     * 生成二维码标签图片
     *
     * @param code      二维码内容(纯码值)
     * @param keyValues 叠加的可读文本(顺序即绘制顺序)
     * @param code        二维码内容(溯源链接或纯码值)
     * @param displayCode 二维码下方显示的原始码值
     * @return 合成图片
     */
    public static BufferedImage createQrLabel(String code, LinkedHashMap<String, String> keyValues) {
    public static BufferedImage createQrLabel(String code, String displayCode) {
        QrConfig config = new QrConfig();
        config.setErrorCorrection(ErrorCorrectionLevel.H); // 打印标签易磨损,用高容错
        config.setMargin(1); // 保证静区,扫码率
        config.setErrorCorrection(ErrorCorrectionLevel.H);
        config.setMargin(4); // 保留二维码静区,保证打印后的识读率
        config.setWidth(QR_SIZE);
        config.setHeight(QR_SIZE);
        BufferedImage qrImage = QrCodeUtil.generate(code, config);
        Font labelFont = new Font(FONT_NAME, Font.BOLD, FONT_SIZE);
        Font valueFont = new Font(FONT_NAME, Font.PLAIN, FONT_SIZE);
        // 第一遍:测量文本块总高度,用于确定图片高度
        int textHeight = measureTextHeight(keyValues, labelFont, valueFont);
        int height = PADDING * 2 + Math.max(QR_SIZE, textHeight);
        int width = PADDING * 2 + QR_SIZE + GAP + TEXT_WIDTH;
        int width = PADDING * 2 + QR_SIZE;
        int height = PADDING * 2 + QR_SIZE + GAP + LINE_HEIGHT;
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = image.createGraphics();
        try {
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
            g.setColor(Color.WHITE);
            g.fillRect(0, 0, width, height);
            // 二维码(垂直居中)
            int qrY = (height - QR_SIZE) / 2;
            g.drawImage(qrImage, PADDING, qrY, null);
            // 可读文本
            int textX = PADDING + QR_SIZE + GAP;
            int textY = (height - textHeight) / 2;
            drawKeyValues(g, keyValues, labelFont, valueFont, textX, textY);
            g.drawImage(qrImage, PADDING, PADDING, null);
            String text = StrUtil.nullToEmpty(displayCode);
            int fontSize = FONT_SIZE;
            g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, fontSize));
            while (g.getFontMetrics().stringWidth(text) > QR_SIZE && fontSize > 1) {
                g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, --fontSize));
            }
            FontMetrics metrics = g.getFontMetrics();
            int textX = (width - metrics.stringWidth(text)) / 2;
            int textY = PADDING + QR_SIZE + GAP
                    + (LINE_HEIGHT - metrics.getHeight()) / 2 + metrics.getAscent();
            g.setColor(Color.BLACK);
            g.drawString(text, textX, textY);
        } finally {
            g.dispose();
        }
@@ -120,7 +120,7 @@
        ZipOutputStream zip = new ZipOutputStream(response.getOutputStream());
        try {
            for (QrLabelInfo label : labels) {
                BufferedImage image = createQrLabel(label.getCode(), label.getKeyValues());
                BufferedImage image = createQrLabel(label.getCode(), label.getFileName());
                zip.putNextEntry(new ZipEntry(label.getFileName() + ".png"));
                ImageIO.write(image, "png", zip);
                zip.closeEntry();
@@ -134,100 +134,46 @@
    // ==================== 私有方法 ====================
    /**
     * 解析当前环境可用的中文字体族名。
     * <p>
     * 不能按字体族名做精确匹配:同一字体在不同系统上的族名可能不同(例如 Windows 上实际注册为
     * {@code Microsoft YaHei UI} 而非 {@code Microsoft YaHei}),精确匹配落空后会一路退到纯拉丁字体,
     * 中文即渲染为方框。因此这里统一用 {@link Font#canDisplay(char)} 探测中文字形。
     *
     * @return 可渲染中文的字体族名;无可用字体时返回 {@code null}
     */
    private static String resolveFontName() {
        Set<String> families = Set.of(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames());
        for (String candidate : FONT_CANDIDATES) {
            if (families.contains(candidate)) {
            if (families.contains(candidate) && canDisplayCjk(candidate)) {
                return candidate;
            }
        }
        return Font.SANS_SERIF;
    }
    private static int measureTextHeight(LinkedHashMap<String, String> keyValues, Font labelFont, Font valueFont) {
        BufferedImage scratch = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = scratch.createGraphics();
        try {
            return measureKeyValuesHeight(g, keyValues, labelFont, valueFont);
        } finally {
            g.dispose();
        // 逻辑字体由 JDK 负责字形回退,多数环境可渲染中文
        if (canDisplayCjk(Font.SANS_SERIF)) {
            return Font.SANS_SERIF;
        }
    }
    private static int measureKeyValuesHeight(Graphics2D g, LinkedHashMap<String, String> keyValues,
                                              Font labelFont, Font valueFont) {
        int total = 0;
        for (Map.Entry<String, String> entry : keyValues.entrySet()) {
            String label = StrUtil.emptyToDefault(entry.getKey(), "") + ":";
            String value = StrUtil.emptyToDefault(entry.getValue(), "");
            g.setFont(labelFont);
            int labelWidth = g.getFontMetrics().stringWidth(label);
            g.setFont(valueFont);
            List<String> lines = wrapText(g, value, TEXT_WIDTH - labelWidth);
            total += Math.max(1, lines.size()) * LINE_HEIGHT + FIELD_GAP;
        }
        return total;
    }
    private static void drawKeyValues(Graphics2D g, LinkedHashMap<String, String> keyValues,
                                      Font labelFont, Font valueFont, int x, int y) {
        FontMetrics labelMetrics = g.getFontMetrics(labelFont);
        int baselineOffset = labelMetrics.getAscent(); // drawString 的 y 是基线,先下移一个 ascent
        int baseline = y + baselineOffset;
        for (Map.Entry<String, String> entry : keyValues.entrySet()) {
            String label = StrUtil.emptyToDefault(entry.getKey(), "") + ":";
            String value = StrUtil.emptyToDefault(entry.getValue(), "");
            g.setFont(labelFont);
            int labelWidth = g.getFontMetrics().stringWidth(label);
            g.setColor(Color.BLACK);
            g.drawString(label, x, baseline);
            g.setFont(valueFont);
            int remainingWidth = TEXT_WIDTH - labelWidth;
            List<String> lines = wrapText(g, value, remainingWidth);
            if (!lines.isEmpty()) {
                g.drawString(lines.get(0), x + labelWidth, baseline);
                baseline += LINE_HEIGHT;
                for (int i = 1; i < lines.size(); i++) {
                    g.drawString(lines.get(i), x, baseline);
                    baseline += LINE_HEIGHT;
                }
            } else {
                baseline += LINE_HEIGHT;
            }
            baseline += FIELD_GAP;
        }
        return null;
    }
    /**
     * 文本按最大宽度换行(逐字测量,兼容中文)
     * 探测指定字体族能否渲染中文
     */
    private static List<String> wrapText(Graphics2D g, String text, int maxWidth) {
        List<String> lines = new ArrayList<>();
        if (StrUtil.isEmpty(text)) {
            return lines;
    private static boolean canDisplayCjk(String family) {
        return new Font(family, Font.PLAIN, FONT_SIZE).canDisplay(CJK_PROBE);
    }
    /**
     * 校验当前环境存在可渲染中文的字体,否则抛出异常。
     * <p>
     * 供启动期调用:宁可在启动时失败,也不能把中文渲染成方框的标签交付印刷。
     */
    public static void assertCjkFontAvailable() {
        if (FONT_NAME == null) {
            throw new IllegalStateException("当前环境没有任何可渲染中文的字体,二维码标签的中文将显示为方框,"
                    + "拒绝生成标签。请在服务器安装中文字体(如 fonts-noto-cjk)后重启。");
        }
        if (maxWidth <= 0) {
            lines.add(text);
            return lines;
        }
        StringBuilder current = new StringBuilder();
        FontMetrics metrics = g.getFontMetrics();
        for (int i = 0; i < text.length(); i++) {
            char ch = text.charAt(i);
            String test = current.toString() + ch;
            if (metrics.stringWidth(test) > maxWidth && current.length() > 0) {
                lines.add(current.toString());
                current = new StringBuilder().append(ch);
            } else {
                current.append(ch);
            }
        }
        if (current.length() > 0) {
            lines.add(current.toString());
        }
        return lines;
    }
}