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;
|
}
|
|
}
|