liu
2 天以前 4ca4444ad33a2a1a51f5c06e78ee9365dfb1eed6
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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
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.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;
 
/**
 * 二维码标签图片工具类
 * <p>
 * 生成"二维码 + 右侧可读文本"的合成图片:二维码编码内容由调用方传入(配置了溯源地址时为
 * 带 code 参数的溯源 URL,未配置时为纯码值),右侧叠加关键信息文本,
 * 即使数据库丢失也能从打印图片还原关键信息。
 */
@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 = 24;
    /** 文本区域宽度 */
    private static final int TEXT_WIDTH = 380;
    /** 字体大小 */
    private static final int FONT_SIZE = 15;
    /** 行高 */
    private static final int LINE_HEIGHT = 24;
    /** 字段间距 */
    private static final int FIELD_GAP = 6;
 
    /**
     * 中文字体候选(按优先级)。
     * <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 keyValues 叠加的可读文本(顺序即绘制顺序)
     * @return 合成图片
     */
    public static BufferedImage createQrLabel(String code, LinkedHashMap<String, String> keyValues) {
        // 无中文字体时直接失败,避免导出中文为方框的标签
        assertCjkFontAvailable();
        QrConfig config = new QrConfig();
        config.setErrorCorrection(ErrorCorrectionLevel.H); // 打印标签易磨损,用高容错
        config.setMargin(1); // 保证静区,扫码率
        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;
 
        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);
        } 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.getKeyValues());
                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)后重启。");
        }
    }
 
    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();
        }
    }
 
    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;
        }
    }
 
    /**
     * 文本按最大宽度换行(逐字测量,兼容中文)
     */
    private static List<String> wrapText(Graphics2D g, String text, int maxWidth) {
        List<String> lines = new ArrayList<>();
        if (StrUtil.isEmpty(text)) {
            return lines;
        }
        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;
    }
 
}