qr_ascii/qr_ascii_qr/generator.py

352 lines
15 KiB
Python
Raw Permalink Normal View History

2025-07-21 07:35:35 +00:00
"""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)