云
4 天以前 0f11bea64e963b2231875760bd4e9103e99b8a63
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package cn.iocoder.yudao.module.mes.framework.qrcode;
 
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
 
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.GraphicsEnvironment;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
 
/**
 * 二维码标签图片工具类
 * <p>
 * 生成二维码在上、码值在下的紧凑标签。二维码内容仍使用调用方传入的溯源链接。
 */
@Slf4j
public final class QrCodeImageUtils {
 
    private QrCodeImageUtils() {
    }
 
    /** 批量导出二维码数量上限(防止一次性生成过多导致内存/响应过大) */
    public static final int MAX_QR_EXPORT = 2000;
 
    /** 二维码边长 */
    private static final int QR_SIZE = 260;
    /** 图片四周留白 */
    private static final int PADDING = 16;
    /** 二维码与文本间距 */
    private static final int GAP = 4;
    /** 字体大小 */
    private static final int FONT_SIZE = 24;
    /** 行高 */
    private static final int LINE_HEIGHT = 32;
 
    /**
     * 中文字体候选(按优先级)。
     * <p>
     * 只允许收录可渲染中文的字体:纯拉丁字体(如 Arial)一旦入选,中文会全部渲染成方框,
     * 且导出过程不报错,属于最隐蔽的一类事故。
     */
    private static final String[] FONT_CANDIDATES = {
            "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 displayCode 二维码下方显示的原始码值
     * @return 合成图片
     */
    public static BufferedImage createQrLabel(String code, String displayCode) {
        QrConfig config = new QrConfig();
        config.setErrorCorrection(ErrorCorrectionLevel.H);
        config.setMargin(4); // 保留二维码静区,保证打印后的识读率
        config.setWidth(QR_SIZE);
        config.setHeight(QR_SIZE);
        BufferedImage qrImage = QrCodeUtil.generate(code, config);
 
        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_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
            g.setColor(Color.WHITE);
            g.fillRect(0, 0, width, height);
            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();
        }
        return image;
    }
 
    /**
     * 将多张二维码标签图片写入 ZIP 响应(流式,逐张生成,内存只占一张)
     *
     * @param response 响应
     * @param zipName  ZIP 文件名(不含后缀)
     * @param labels   标签列表
     */
    public static void writeZip(HttpServletResponse response, String zipName, List<QrLabelInfo> labels) throws IOException {
        response.setContentType("application/zip");
        response.setHeader("Content-Disposition",
                "attachment;filename=" + HttpUtils.encodeUtf8(zipName + ".zip"));
        ZipOutputStream zip = new ZipOutputStream(response.getOutputStream());
        try {
            for (QrLabelInfo label : labels) {
                BufferedImage image = createQrLabel(label.getCode(), label.getFileName());
                zip.putNextEntry(new ZipEntry(label.getFileName() + ".png"));
                ImageIO.write(image, "png", zip);
                zip.closeEntry();
            }
            zip.finish();
            zip.flush();
        } finally {
            // 不 close ZipOutputStream,避免关闭底层 ServletOutputStream(与 FileTypeUtils 同理)
        }
    }
 
    // ==================== 私有方法 ====================
 
    /**
     * 解析当前环境可用的中文字体族名。
     * <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) && canDisplayCjk(candidate)) {
                return candidate;
            }
        }
        // 逻辑字体由 JDK 负责字形回退,多数环境可渲染中文
        if (canDisplayCjk(Font.SANS_SERIF)) {
            return Font.SANS_SERIF;
        }
        return null;
    }
 
    /**
     * 探测指定字体族能否渲染中文
     */
    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)后重启。");
        }
    }
 
}