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)
|