| | |
| | | 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> |
| | | * 生成"二维码 + 右侧可读文本"的合成图片:二维码编码内容由调用方传入(配置了溯源地址时为 |
| | | * 带 code 参数的溯源 URL,未配置时为纯码值),右侧叠加关键信息文本, |
| | | * 即使数据库丢失也能从打印图片还原关键信息。 |
| | | * 生成二维码在上、码值在下的紧凑标签。二维码内容仍使用调用方传入的溯源链接。 |
| | | */ |
| | | @Slf4j |
| | | public final class QrCodeImageUtils { |
| | |
| | | /** 图片四周留白 */ |
| | | private static final int PADDING = 16; |
| | | /** 二维码与文本间距 */ |
| | | private static final int GAP = 24; |
| | | /** 文本区域宽度 */ |
| | | private static final int TEXT_WIDTH = 380; |
| | | private static final int GAP = 4; |
| | | /** 字体大小 */ |
| | | private static final int FONT_SIZE = 15; |
| | | private static final int FONT_SIZE = 24; |
| | | /** 行高 */ |
| | | private static final int LINE_HEIGHT = 24; |
| | | /** 字段间距 */ |
| | | private static final int FIELD_GAP = 6; |
| | | private static final int LINE_HEIGHT = 32; |
| | | |
| | | /** |
| | | * 中文字体候选(按优先级)。 |
| | |
| | | /** |
| | | * 生成二维码标签图片 |
| | | * |
| | | * @param code 二维码内容(纯码值) |
| | | * @param keyValues 叠加的可读文本(顺序即绘制顺序) |
| | | * @param code 二维码内容(溯源链接或纯码值) |
| | | * @param displayCode 二维码下方显示的原始码值 |
| | | * @return 合成图片 |
| | | */ |
| | | public static BufferedImage createQrLabel(String code, LinkedHashMap<String, String> keyValues) { |
| | | // 无中文字体时直接失败,避免导出中文为方框的标签 |
| | | assertCjkFontAvailable(); |
| | | public static BufferedImage createQrLabel(String code, String displayCode) { |
| | | QrConfig config = new QrConfig(); |
| | | config.setErrorCorrection(ErrorCorrectionLevel.H); // 打印标签易磨损,用高容错 |
| | | config.setMargin(1); // 保证静区,扫码率 |
| | | config.setErrorCorrection(ErrorCorrectionLevel.H); |
| | | config.setMargin(4); // 保留二维码静区,保证打印后的识读率 |
| | | 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; |
| | | |
| | | 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_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); |
| | | 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(); |
| | | } |
| | |
| | | ZipOutputStream zip = new ZipOutputStream(response.getOutputStream()); |
| | | try { |
| | | for (QrLabelInfo label : labels) { |
| | | BufferedImage image = createQrLabel(label.getCode(), label.getKeyValues()); |
| | | BufferedImage image = createQrLabel(label.getCode(), label.getFileName()); |
| | | zip.putNextEntry(new ZipEntry(label.getFileName() + ".png")); |
| | | ImageIO.write(image, "png", zip); |
| | | zip.closeEntry(); |
| | |
| | | throw new IllegalStateException("当前环境没有任何可渲染中文的字体,二维码标签的中文将显示为方框," |
| | | + "拒绝生成标签。请在服务器安装中文字体(如 fonts-noto-cjk)后重启。"); |
| | | } |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | |
| | | } |