commit e6ec79ba643a70326959bf2451531b130e2e4b6a Author: 齐斌 <1134087124@qq.com> Date: Mon Jul 21 15:35:35 2025 +0800 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..3045b4b --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# QR-ASCII-QR + +一个强大且灵活的二维码生成工具,能够创建独特的"字符二维码"——在传统二维码的基础上叠加自定义字符,既保持完整的扫描功能,又具有独特的视觉效果。 + +[![Python Version](https://img.shields.io/badge/python-3.7+-blue.svg)](https://python.org) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) + +## ✨ 主要特色 + +### 🎯 多种渲染模式 +- **纯黑块模式 (solid)**: 传统的黑白二维码 +- **纯文字模式 (text)**: 仅使用字符构成的二维码(定位点保持黑块) +- **组合模式 (combined)**: 黑色背景上叠加字符,视觉效果最佳 + +### 🔧 强大的自定义功能 +- **自定义字符集**: 支持数字、字母、汉字等任意字符组合 +- **多种输出格式**: PNG 位图和 SVG 矢量图 +- **字体自定义**: 支持 TrueType 字体文件 +- **Logo 嵌入**: 可在二维码中心添加 Logo(PNG 模式) +- **高纠错级别**: 默认 H 级(30% 容错),确保可扫描性 + +### 🚀 批量生成 +- **三合一模式**: 一次命令生成三种不同风格的二维码 +- **智能定位点处理**: 自动识别并优化定位点区域的显示效果 + +## 📦 安装 + +### 环境要求 +- Python 3.7 或更高版本 +- 支持 Windows、macOS、Linux + +### 快速安装 +```bash +# 2. 创建虚拟环境(推荐) +python -m venv venv + +# 3. 激活虚拟环境 +# Windows: +venv\Scripts\activate +# macOS/Linux: +source venv/bin/activate + +# 4. 安装依赖 +pip install -r requirements.txt +``` + +### 依赖包说明 +- `qrcode>=7.4.2`: 二维码生成核心库 +- `pillow>=10.0.0`: 图像处理库,用于 PNG 渲染 +- `svgwrite>=1.4.3`: SVG 文件生成库 + +## 🎮 快速开始 + +### 基础使用 +```bash +# 只生成字符二维码 +python main.py -d "网站URL" -o "文件名" -c "hash值" --text-only + +# 生成三种二维码 +python main.py -d "网站URL" -o "文件名" -c "hash值" --three-types + + +### 高级使用 +```bash +# 使用自定义字体和 Logo +python main.py -d "Branded QR" -o branded.png \ + --font ./SIMHEI.TTF \ + --logo ./logo.png \ + -s 30 + +# 指定纠错级别 +python main.py -d "High Error Correction" -o high_ec.png -e H + +# 大尺寸二维码 +python main.py -d "Large QR" -o large.png -s 50 +``` + +## 📖 详细参数说明 + +### 命令行参数 + +| 参数 | 长格式 | 必需 | 默认值 | 说明 | +|------|--------|------|--------|------| +| `-d` | `--data` | ✅ | - | 要编码的数据(文本、URL、任意字符串) | +| `-o` | `--out` | ✅ | - | 输出文件路径,支持 .png 和 .svg 扩展名 | +| `-s` | `--cell-size` | ❌ | 20 | 单元格像素大小(仅 PNG 有效) | +| `-c` | `--charset` | ❌ | `0-9A-Z` | 用作填充的字符集合 | +| `-e` | `--ec-level` | ❌ | H | 纠错级别:L(7%) / M(15%) / Q(25%) / H(30%) | +| | `--font` | ❌ | 自动检测 | TrueType 字体文件路径(仅 PNG 有效) | +| | `--logo` | ❌ | - | Logo 图片路径(仅 PNG 有效) | +| | `--svg` | ❌ | false | 强制输出 SVG 格式 | +| | `--three-types` | ❌ | false | 生成三种渲染模式的二维码 | + + + +## 🔧 API 使用 + +### 基本 API +```python +from qr_ascii_qr import generate_qr_image, generate_qr_svg + +# 生成 PNG 图片 +img = generate_qr_image( + data="Hello API", + cell_size=25, + charset="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + ec_level="H" +) +img.show() # 显示图片 +img.save("api_example.png") # 保存图片 + +# 生成 SVG 文件 +svg_path = generate_qr_svg( + data="SVG Example", + out_path="api_example.svg", + cell_size=20, + charset="★☆♦♣♠♥♪♫" +) +``` + +### 高级 API +```python +from qr_ascii_qr import generate_matrix, render_to_png, render_to_svg + +# 生成矩阵 +matrix = generate_matrix("Advanced Usage", ec_level="H") + +# 渲染为 PNG(支持多种模式) +render_to_png( + matrix=matrix, + out_path="advanced.png", + cell_size=30, + charset="0123456789ABCDEF", + font_path="./fonts/custom.ttf", + logo_path="./logo.png", + render_mode="combined" # 'solid', 'text', 'combined' +) + +# 渲染为 SVG +render_to_svg( + matrix=matrix, + out_path="advanced.svg", + cell_size=25, + charset="♠♣♥♦", + font_family="Arial" +) +``` + +## 🎨 渲染模式详解 + +### 1. 纯黑块模式 (solid) +传统的黑白二维码,与标准二维码完全一致。 + +### 2. 纯文字模式 (text) +- 定位点区域:保持黑色方块(确保扫描识别) +- 数据区域:使用指定字符替代黑色方块 +- 背景:白色 +- 适用场景:需要清晰可读字符的场合 + +### 3. 组合模式 (combined) +- 保持原有黑色方块 +- 在黑色背景上叠加字符 +- 字符颜色:黑色(在实际应用中可以调整为其他颜色) +- 视觉效果:最佳的艺术效果与扫描性能平衡 + +## 📁 项目结构 + +``` +qr_ascii_qr/ +├── qr_ascii_qr/ # 核心包 +│ ├── __init__.py # 包导出接口 +│ └── generator.py # 二维码生成核心逻辑 +├── main.py # 命令行入口 +├── requirements.txt # 项目依赖 +├── SIMHEI.TTF # 默认中文字体 +├── logo.png # 示例 Logo +├── README.md # 项目文档 +└── venv/ # 虚拟环境(安装后生成) +``` + + + +## 🐛 故障排除 + +### 常见问题 + +**Q: 生成的二维码无法扫描?** +A: 检查以下几点: +- 确保使用足够高的纠错级别(推荐 H 级) +- 验证单元格大小是否足够大(建议 ≥ 20 像素) +- 在组合模式下,确保字符颜色与背景有足够对比度 + +**Q: 字符显示不正确或乱码?** +A: +- 确保使用支持目标字符的字体文件 +- 检查字符集编码是否正确(推荐使用 UTF-8) +- 验证字体文件路径是否正确 + +**Q: 程序运行缓慢?** +A: +- 减小单元格大小以降低图片分辨率 +- 简化字符集,减少复杂字符的使用 +- 考虑使用 SVG 格式替代大尺寸 PNG + +### 错误代码 +- `FileNotFoundError`: 字体文件或 Logo 文件路径错误 +- `UnicodeError`: 字符集包含不支持的字符 +- `PIL.UnidentifiedImageError`: Logo 文件格式不支持 + diff --git a/SIMHEI.TTF b/SIMHEI.TTF new file mode 100644 index 0000000..e85ffd1 Binary files /dev/null and b/SIMHEI.TTF differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..63a6610 Binary files /dev/null and b/logo.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..7237c3e --- /dev/null +++ b/main.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +"""Command‑line interface for QR-ASCII-QR.""" +import argparse +import os +from qr_ascii_qr import generate_matrix, render_to_png, render_to_svg + +def main(): + parser = argparse.ArgumentParser(description="Generate an ASCII-character QR code.") + parser.add_argument("-d", "--data", required=True, help="Data to encode") + parser.add_argument("-o", "--out", required=True, help="Output file prefix (.png or .svg)") + parser.add_argument("-s", "--cell-size", type=int, default=20, help="Pixel size of each cell (PNG only)") + parser.add_argument("-c", "--charset", default="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", help="Characters to use") + parser.add_argument("-e", "--ec-level", default="H", choices=list("LMQH"), help="Error correction level") + parser.add_argument("--font", help="TrueType font path (PNG only)") + parser.add_argument("--logo", help="Logo image path (PNG only)") + parser.add_argument("--svg", action="store_true", help="Force SVG output") + parser.add_argument("--three-types", action="store_true", help="Generate three types of QR codes") + parser.add_argument("--text-only", action="store_true", help="Generate only text QR code") + args = parser.parse_args() + + matrix = generate_matrix(args.data, args.ec_level) + + if args.text_only: + # 只生成纯文字二维码 + ext = args.out.lower().split(".")[-1] + if args.svg or ext == "svg": + render_to_svg(matrix, args.out, cell_size=args.cell_size, charset=args.charset) + else: + render_to_png( + matrix, + args.out, + cell_size=args.cell_size, + charset=args.charset, + font_path=args.font, + logo_path=args.logo, + render_mode="text" + ) + print(f"已保存纯文字二维码: {args.out}") + elif args.three_types: + # 生成三种类型的图片 + base_name, ext = os.path.splitext(args.out) + if args.svg or ext.lower() == ".svg": + # SVG模式 + render_to_svg(matrix, f"{base_name}_combined{ext}", cell_size=args.cell_size, charset=args.charset) + print(f"Saved {base_name}_combined{ext}") + else: + # PNG模式 - 生成三张图片 + # 1. 纯黑块二维码 + solid_file = f"{base_name}_solid{ext}" + render_to_png( + matrix, + solid_file, + cell_size=args.cell_size, + charset=args.charset, + font_path=args.font, + logo_path=args.logo, + render_mode="solid" + ) + print(f"已保存纯黑块二维码: {solid_file}") + + # 2. 纯文字二维码 + text_file = f"{base_name}_text{ext}" + render_to_png( + matrix, + text_file, + cell_size=args.cell_size, + charset=args.charset, + font_path=args.font, + logo_path=args.logo, + render_mode="text" + ) + print(f"已保存纯文字二维码: {text_file}") + + # 3. 结合二维码(黑色背景+文字) + combined_file = f"{base_name}_combined{ext}" + render_to_png( + matrix, + combined_file, + cell_size=args.cell_size, + charset=args.charset, + font_path=args.font, + logo_path=args.logo, + render_mode="combined" + ) + print(f"已保存结合二维码: {combined_file}") + else: + # 原有的单个文件生成逻辑 + ext = args.out.lower().split(".")[-1] + if args.svg or ext == "svg": + render_to_svg(matrix, args.out, cell_size=args.cell_size, charset=args.charset) + else: + render_to_png( + matrix, + args.out, + cell_size=args.cell_size, + charset=args.charset, + font_path=args.font, + logo_path=args.logo, + ) + print(f"Saved {args.out}") + +if __name__ == "__main__": + main() diff --git a/qr_ascii_qr/__init__.py b/qr_ascii_qr/__init__.py new file mode 100644 index 0000000..eeb6e68 --- /dev/null +++ b/qr_ascii_qr/__init__.py @@ -0,0 +1,7 @@ +from .generator import ( + generate_matrix, + render_to_png, + render_to_svg, + generate_qr_image, + generate_qr_svg, +) diff --git a/qr_ascii_qr/__pycache__/__init__.cpython-313.pyc b/qr_ascii_qr/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..02a3ae8 Binary files /dev/null and b/qr_ascii_qr/__pycache__/__init__.cpython-313.pyc differ diff --git a/qr_ascii_qr/__pycache__/generator.cpython-313.pyc b/qr_ascii_qr/__pycache__/generator.cpython-313.pyc new file mode 100644 index 0000000..43b6e90 Binary files /dev/null and b/qr_ascii_qr/__pycache__/generator.cpython-313.pyc differ diff --git a/qr_ascii_qr/generator.py b/qr_ascii_qr/generator.py new file mode 100644 index 0000000..40f6559 --- /dev/null +++ b/qr_ascii_qr/generator.py @@ -0,0 +1,351 @@ +"""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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1cd687d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +qrcode>=7.4.2 +pillow>=10.0.0 +svgwrite>=1.4.3