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

* 生成二维码在上、码值在下的紧凑标签。二维码内容仍使用调用方传入的溯源链接。 */ @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; /** * 中文字体候选(按优先级)。 *

* 只允许收录可渲染中文的字体:纯拉丁字体(如 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 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 同理) } } // ==================== 私有方法 ==================== /** * 解析当前环境可用的中文字体族名。 *

* 不能按字体族名做精确匹配:同一字体在不同系统上的族名可能不同(例如 Windows 上实际注册为 * {@code Microsoft YaHei UI} 而非 {@code Microsoft YaHei}),精确匹配落空后会一路退到纯拉丁字体, * 中文即渲染为方框。因此这里统一用 {@link Font#canDisplay(char)} 探测中文字形。 * * @return 可渲染中文的字体族名;无可用字体时返回 {@code null} */ private static String resolveFontName() { Set 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); } /** * 校验当前环境存在可渲染中文的字体,否则抛出异常。 *

* 供启动期调用:宁可在启动时失败,也不能把中文渲染成方框的标签交付印刷。 */ public static void assertCjkFontAvailable() { if (FONT_NAME == null) { throw new IllegalStateException("当前环境没有任何可渲染中文的字体,二维码标签的中文将显示为方框," + "拒绝生成标签。请在服务器安装中文字体(如 fonts-noto-cjk)后重启。"); } } }