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; /** * 二维码标签图片工具类 *

* 生成"二维码 + 右侧可读文本"的合成图片:二维码只编码纯码值,文本叠加关键信息, * 即使数据库丢失也能从打印图片还原关键信息。 */ @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 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 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 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 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 keyValues, Font labelFont, Font valueFont) { int total = 0; for (Map.Entry 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 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 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 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 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 wrapText(Graphics2D g, String text, int maxWidth) { List 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; } }