352 lines
15 KiB
Python
352 lines
15 KiB
Python
|
"""QR code rendered with alphanumeric characters."""
|
|||
|
import qrcode
|
|||
|
from PIL import Image, ImageDraw, ImageFont
|
|||
|
import svgwrite
|
|||
|
from typing import List
|
|||
|
import os
|
|||
|
|
|||
|
DEFAULT_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|||
|
|
|||
|
def is_finder_pattern(x: int, y: int, matrix_size: int) -> bool:
|
|||
|
"""判断给定坐标是否在QR码的三个定位点区域内(包含分隔符的8x8模块)"""
|
|||
|
# 边框已被裁剪,定位点从(0,0)开始
|
|||
|
|
|||
|
# 左上角定位点区域 (0,0) 到 (7,7) - 包含7x7定位点和1像素分隔符
|
|||
|
if 0 <= x <= 7 and 0 <= y <= 7:
|
|||
|
return True
|
|||
|
|
|||
|
# 右上角定位点区域 (matrix_size-8,0) 到 (matrix_size-1,7)
|
|||
|
if (matrix_size - 8) <= x <= (matrix_size - 1) and 0 <= y <= 7:
|
|||
|
return True
|
|||
|
|
|||
|
# 左下角定位点区域 (0,matrix_size-8) 到 (7,matrix_size-1)
|
|||
|
if 0 <= x <= 7 and (matrix_size - 8) <= y <= (matrix_size - 1):
|
|||
|
return True
|
|||
|
|
|||
|
return False
|
|||
|
|
|||
|
def generate_matrix(data: str, ec_level: str = "H") -> List[List[bool]]:
|
|||
|
"""Return boolean matrix of the QR code."""
|
|||
|
level_map = {
|
|||
|
"L": qrcode.constants.ERROR_CORRECT_L,
|
|||
|
"M": qrcode.constants.ERROR_CORRECT_M,
|
|||
|
"Q": qrcode.constants.ERROR_CORRECT_Q,
|
|||
|
"H": qrcode.constants.ERROR_CORRECT_H,
|
|||
|
}
|
|||
|
qr = qrcode.QRCode(error_correction=level_map.get(ec_level.upper(), qrcode.constants.ERROR_CORRECT_H))
|
|||
|
qr.add_data(data)
|
|||
|
qr.make()
|
|||
|
return qr.get_matrix()
|
|||
|
|
|||
|
def render_to_png(
|
|||
|
matrix,
|
|||
|
out_path: str,
|
|||
|
cell_size: int = 20,
|
|||
|
charset: str = DEFAULT_CHARSET,
|
|||
|
font_path: str | None = None,
|
|||
|
logo_path: str | None = None,
|
|||
|
render_mode: str = "combined", # 新增参数:'solid', 'text', 'combined'
|
|||
|
):
|
|||
|
# 去掉QR码的4像素边框
|
|||
|
border = 4
|
|||
|
original_size = len(matrix)
|
|||
|
|
|||
|
# 裁剪矩阵,去掉边框
|
|||
|
cropped_matrix = []
|
|||
|
for y in range(border, original_size - border):
|
|||
|
row = []
|
|||
|
for x in range(border, original_size - border):
|
|||
|
row.append(matrix[y][x])
|
|||
|
cropped_matrix.append(row)
|
|||
|
|
|||
|
matrix = cropped_matrix
|
|||
|
N = len(matrix)
|
|||
|
img_size = N * cell_size
|
|||
|
img = Image.new("RGBA", (img_size, img_size), (255, 255, 255, 0))
|
|||
|
draw = ImageDraw.Draw(img)
|
|||
|
|
|||
|
|
|||
|
|
|||
|
# 设置字体,优先使用指定字体或项目中的字体
|
|||
|
font_size = int(cell_size * 0.7) # 基础字体大小
|
|||
|
font_size_large = int(cell_size * 0.9) # 第一个和最后一个字符的更大字体
|
|||
|
|
|||
|
if font_path:
|
|||
|
try:
|
|||
|
font = ImageFont.truetype(font_path, font_size)
|
|||
|
font_large = ImageFont.truetype(font_path, font_size_large)
|
|||
|
except:
|
|||
|
font = ImageFont.load_default()
|
|||
|
font_large = font
|
|||
|
else:
|
|||
|
# 尝试使用项目目录中的字体
|
|||
|
try:
|
|||
|
# 检查当前目录和上级目录中的字体文件
|
|||
|
possible_fonts = [
|
|||
|
"SIMHEI.TTF",
|
|||
|
"../SIMHEI.TTF",
|
|||
|
"../../SIMHEI.TTF",
|
|||
|
os.path.join(os.path.dirname(__file__), "..", "SIMHEI.TTF")
|
|||
|
]
|
|||
|
font_loaded = False
|
|||
|
for font_file in possible_fonts:
|
|||
|
if os.path.exists(font_file):
|
|||
|
font = ImageFont.truetype(font_file, font_size)
|
|||
|
font_large = ImageFont.truetype(font_file, font_size_large)
|
|||
|
font_loaded = True
|
|||
|
break
|
|||
|
|
|||
|
if not font_loaded:
|
|||
|
# 尝试系统字体
|
|||
|
try:
|
|||
|
font = ImageFont.truetype("arial.ttf", font_size)
|
|||
|
font_large = ImageFont.truetype("arial.ttf", font_size_large)
|
|||
|
except:
|
|||
|
font = ImageFont.load_default()
|
|||
|
font_large = font
|
|||
|
except:
|
|||
|
font = ImageFont.load_default()
|
|||
|
font_large = font
|
|||
|
|
|||
|
# 绘制二维码
|
|||
|
# 计算需要绘制的总位置数,确保至少包含一次完整字符串
|
|||
|
total_positions = 0
|
|||
|
for y, row in enumerate(matrix):
|
|||
|
for x, bit in enumerate(row):
|
|||
|
if bit:
|
|||
|
total_positions += 1
|
|||
|
|
|||
|
# 如果字符串长度大于总位置数,调整字符串以确保覆盖
|
|||
|
effective_charset = charset
|
|||
|
if len(charset) > total_positions and total_positions > 0:
|
|||
|
# 如果字符串太长,截取到能完整显示的长度
|
|||
|
effective_charset = charset[:total_positions]
|
|||
|
|
|||
|
# 开始绘制,使用优化后的字符分配
|
|||
|
position_counter = 0
|
|||
|
for y, row in enumerate(matrix):
|
|||
|
for x, bit in enumerate(row):
|
|||
|
|
|||
|
if bit:
|
|||
|
x_pos = x * cell_size
|
|||
|
y_pos = y * cell_size
|
|||
|
|
|||
|
# 使用位置计数器来确保字符串的完整性
|
|||
|
ch = effective_charset[position_counter % len(effective_charset)]
|
|||
|
char_index = position_counter % len(effective_charset)
|
|||
|
position_counter += 1
|
|||
|
|
|||
|
# 根据渲染模式绘制不同内容
|
|||
|
if render_mode == "solid":
|
|||
|
# 只绘制黑色方块
|
|||
|
draw.rectangle(
|
|||
|
[x_pos, y_pos, x_pos + cell_size - 1, y_pos + cell_size - 1],
|
|||
|
fill="black"
|
|||
|
)
|
|||
|
|
|||
|
elif render_mode == "text":
|
|||
|
# 检查是否为定位点区域
|
|||
|
if is_finder_pattern(x, y, N):
|
|||
|
# 定位点区域绘制黑色方块
|
|||
|
draw.rectangle(
|
|||
|
[x_pos, y_pos, x_pos + cell_size - 1, y_pos + cell_size - 1],
|
|||
|
fill="black"
|
|||
|
)
|
|||
|
else:
|
|||
|
# 非定位点区域绘制文字(黑色文字在白色背景上)
|
|||
|
# 判断字符类型
|
|||
|
is_chinese = '\u4e00' <= ch <= '\u9fff'
|
|||
|
is_uppercase = ch.isupper()
|
|||
|
is_lowercase = ch.islower()
|
|||
|
is_digit = ch.isdigit()
|
|||
|
|
|||
|
# 判断是否是第一个或最后一个字符,选择对应字体
|
|||
|
# 只有第一次循环的首尾字符才放大
|
|||
|
is_first_cycle = position_counter <= len(effective_charset)
|
|||
|
is_first_or_last_in_cycle = char_index == 0 or char_index == len(effective_charset) - 1
|
|||
|
is_first_or_last = is_first_cycle and is_first_or_last_in_cycle
|
|||
|
current_font = font_large if is_first_or_last else font
|
|||
|
|
|||
|
# 使用方块的中心点作为文字的中心
|
|||
|
center_x = x_pos + cell_size // 2
|
|||
|
center_y = y_pos + cell_size // 2
|
|||
|
|
|||
|
try:
|
|||
|
# 根据字符类型调整垂直偏移
|
|||
|
if is_chinese:
|
|||
|
# 中文字符直接居中
|
|||
|
draw.text(
|
|||
|
(center_x, center_y),
|
|||
|
ch,
|
|||
|
font=current_font,
|
|||
|
fill="black",
|
|||
|
anchor="mm"
|
|||
|
)
|
|||
|
else:
|
|||
|
# 英文和数字需要特殊处理
|
|||
|
# 获取文字边界框以进行精确调整
|
|||
|
bbox = draw.textbbox((0, 0), ch, font=current_font)
|
|||
|
text_width = bbox[2] - bbox[0]
|
|||
|
text_height = bbox[3] - bbox[1]
|
|||
|
|
|||
|
# 计算基础位置
|
|||
|
text_x = center_x - text_width // 2 - bbox[0]
|
|||
|
text_y = center_y - text_height // 2 - bbox[1]
|
|||
|
|
|||
|
# 根据字符类型进行垂直调整
|
|||
|
if is_uppercase or is_digit:
|
|||
|
# 大写字母和数字向上偏移
|
|||
|
text_y -= int(cell_size * 0.05)
|
|||
|
elif is_lowercase:
|
|||
|
# 小写字母可能需要不同的调整
|
|||
|
text_y -= int(cell_size * 0.03)
|
|||
|
|
|||
|
draw.text((text_x, text_y), ch, font=current_font, fill="black")
|
|||
|
|
|||
|
except Exception as e:
|
|||
|
# 如果出现任何错误,使用简单的中心定位
|
|||
|
bbox = draw.textbbox((0, 0), ch, font=current_font)
|
|||
|
text_width = bbox[2] - bbox[0]
|
|||
|
text_height = bbox[3] - bbox[1]
|
|||
|
text_x = center_x - text_width // 2
|
|||
|
text_y = center_y - text_height // 2
|
|||
|
draw.text((text_x, text_y), ch, font=current_font, fill="black")
|
|||
|
|
|||
|
elif render_mode == "combined":
|
|||
|
# 先绘制黑色方块
|
|||
|
draw.rectangle(
|
|||
|
[x_pos, y_pos, x_pos + cell_size - 1, y_pos + cell_size - 1],
|
|||
|
fill="black"
|
|||
|
)
|
|||
|
|
|||
|
# 在黑色方块上绘制灰色文字 RGB(102, 102, 102)
|
|||
|
# 判断字符类型
|
|||
|
is_chinese = '\u4e00' <= ch <= '\u9fff'
|
|||
|
is_uppercase = ch.isupper()
|
|||
|
is_lowercase = ch.islower()
|
|||
|
is_digit = ch.isdigit()
|
|||
|
|
|||
|
# 使用方块的中心点作为文字的中心
|
|||
|
center_x = x_pos + cell_size // 2
|
|||
|
center_y = y_pos + cell_size // 2
|
|||
|
|
|||
|
try:
|
|||
|
# 根据字符类型调整垂直偏移
|
|||
|
if is_chinese:
|
|||
|
# 中文字符直接居中
|
|||
|
draw.text(
|
|||
|
(center_x, center_y),
|
|||
|
ch,
|
|||
|
font=font,
|
|||
|
fill="black",
|
|||
|
anchor="mm"
|
|||
|
)
|
|||
|
else:
|
|||
|
# 英文和数字需要特殊处理
|
|||
|
# 获取文字边界框以进行精确调整
|
|||
|
bbox = draw.textbbox((0, 0), ch, font=font)
|
|||
|
text_width = bbox[2] - bbox[0]
|
|||
|
text_height = bbox[3] - bbox[1]
|
|||
|
|
|||
|
# 计算基础位置
|
|||
|
text_x = center_x - text_width // 2 - bbox[0]
|
|||
|
text_y = center_y - text_height // 2 - bbox[1]
|
|||
|
|
|||
|
# 根据字符类型进行垂直调整
|
|||
|
if is_uppercase or is_digit:
|
|||
|
# 大写字母和数字向上偏移
|
|||
|
text_y -= int(cell_size * 0.05)
|
|||
|
elif is_lowercase:
|
|||
|
# 小写字母可能需要不同的调整
|
|||
|
text_y -= int(cell_size * 0.03)
|
|||
|
|
|||
|
draw.text((text_x, text_y), ch, font=font, fill="black")
|
|||
|
|
|||
|
except Exception as e:
|
|||
|
# 如果出现任何错误,使用简单的中心定位
|
|||
|
bbox = draw.textbbox((0, 0), ch, font=font)
|
|||
|
text_width = bbox[2] - bbox[0]
|
|||
|
text_height = bbox[3] - bbox[1]
|
|||
|
text_x = center_x - text_width // 2
|
|||
|
text_y = center_y - text_height // 2
|
|||
|
draw.text((text_x, text_y), ch, font=font, fill="black")
|
|||
|
|
|||
|
img.save(out_path)
|
|||
|
return img
|
|||
|
|
|||
|
def render_to_svg(
|
|||
|
matrix,
|
|||
|
out_path: str,
|
|||
|
cell_size: int = 20,
|
|||
|
charset: str = DEFAULT_CHARSET,
|
|||
|
font_family: str = "Courier New",
|
|||
|
):
|
|||
|
# 去掉QR码的4像素边框
|
|||
|
border = 4
|
|||
|
original_size = len(matrix)
|
|||
|
|
|||
|
# 裁剪矩阵,去掉边框
|
|||
|
cropped_matrix = []
|
|||
|
for y in range(border, original_size - border):
|
|||
|
row = []
|
|||
|
for x in range(border, original_size - border):
|
|||
|
row.append(matrix[y][x])
|
|||
|
cropped_matrix.append(row)
|
|||
|
|
|||
|
matrix = cropped_matrix
|
|||
|
N = len(matrix)
|
|||
|
size = N * cell_size
|
|||
|
dwg = svgwrite.Drawing(out_path, size=(size, size), profile="tiny")
|
|||
|
|
|||
|
# 计算字符分配
|
|||
|
total_positions = sum(sum(row) for row in matrix)
|
|||
|
effective_charset = charset
|
|||
|
if len(charset) > total_positions and total_positions > 0:
|
|||
|
effective_charset = charset[:total_positions]
|
|||
|
|
|||
|
position_counter = 0
|
|||
|
for y, row in enumerate(matrix):
|
|||
|
for x, bit in enumerate(row):
|
|||
|
if bit:
|
|||
|
x_pos = x * cell_size
|
|||
|
y_pos = y * cell_size
|
|||
|
|
|||
|
# 检查是否为定位点区域
|
|||
|
if is_finder_pattern(x, y, N):
|
|||
|
# 定位点区域只绘制黑色方块
|
|||
|
dwg.add(
|
|||
|
dwg.rect(
|
|||
|
insert=(x_pos, y_pos),
|
|||
|
size=(cell_size, cell_size),
|
|||
|
fill="black"
|
|||
|
)
|
|||
|
)
|
|||
|
else:
|
|||
|
# 非定位点区域绘制字符
|
|||
|
ch = effective_charset[position_counter % len(effective_charset)]
|
|||
|
position_counter += 1
|
|||
|
|
|||
|
# 调整 y 位置以实现垂直居中效果
|
|||
|
text_y = y_pos + cell_size * 0.65
|
|||
|
dwg.add(
|
|||
|
dwg.text(
|
|||
|
ch,
|
|||
|
insert=(x_pos + cell_size * 0.5, text_y),
|
|||
|
font_size=cell_size * 0.6,
|
|||
|
font_family=font_family,
|
|||
|
fill="black",
|
|||
|
text_anchor="middle"
|
|||
|
)
|
|||
|
)
|
|||
|
dwg.save()
|
|||
|
return out_path
|
|||
|
|
|||
|
# Convenience wrappers
|
|||
|
def generate_qr_image(data: str, **kwargs):
|
|||
|
matrix = generate_matrix(data, kwargs.pop("ec_level", "H"))
|
|||
|
return render_to_png(matrix, kwargs.get("out_path", "qr.png"), **kwargs)
|
|||
|
|
|||
|
def generate_qr_svg(data: str, **kwargs):
|
|||
|
matrix = generate_matrix(data, kwargs.pop("ec_level", "H"))
|
|||
|
return render_to_svg(matrix, kwargs.get("out_path", "qr.svg"), **kwargs)
|