4 天以前 06321d70dd7617fdc7d475b190e629143fec3263
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
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>
 * 生成"二维码 + 右侧可读文本"的合成图片:二维码只编码纯码值,文本叠加关键信息,
 * 即使数据库丢失也能从打印图片还原关键信息。
 */
@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;
 
    /** 中文字体候选(按优先级),避免 Linux headless 下文字变方框 */
    private static final String[] FONT_CANDIDATES = {
            "Noto Sans CJK SC", "WenQuanYi Zen Hei", "Microsoft YaHei", "SimSun", "Arial"
    };
 
    private static final String FONT_NAME = resolveFontName();
 
    /**
     * 生成二维码标签图片
     *
     * @param code      二维码内容(纯码值)
     * @param keyValues 叠加的可读文本(顺序即绘制顺序)
     * @return 合成图片
     */
    public static BufferedImage createQrLabel(String code, LinkedHashMap<String, String> keyValues) {
        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 同理)
        }
    }
 
    // ==================== 私有方法 ====================
 
    private static String resolveFontName() {
        Set<String> families = Set.of(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames());
        for (String candidate : FONT_CANDIDATES) {
            if (families.contains(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();
        }
    }
 
    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;
    }
 
}