qr_ascii/qr_ascii_qr/generator.py
2025-07-21 15:35:35 +08:00

352 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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