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)后重启。");
|
}
|
}
|
|
}
|