237 lines
7.4 KiB
Python
237 lines
7.4 KiB
Python
"""二维码往返自检(开发工具,不参与运行时)。
|
||
|
||
作用:解码我们自己生成的 QR SVG,验证 **能被读回原文本**——避免"看起来像二维码
|
||
但扫不出来"的假成功。这不是完整 QR 解码器,只覆盖生成器用到的子集
|
||
(Model 2 / 字节模式 / 纠错 L / 掩码 0),用于回归验证。
|
||
|
||
用法:
|
||
python -m apps.printing.qr_selftest
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import re
|
||
import sys
|
||
|
||
from .qr import VERSIONS_L, ALIGN_POS, _size_of, _pick_version
|
||
|
||
|
||
def _svg_to_matrix(svg: str) -> list:
|
||
"""从生成的 SVG 还原模块矩阵(正方形 rect 拼回去,无需图像库)。"""
|
||
m = re.search(r'viewBox="0 0 (\d+) (\d+)"', svg)
|
||
if not m:
|
||
raise ValueError("no viewBox")
|
||
total = int(m.group(1))
|
||
# 模块边长 = 总边长 / (size + 2*border),从第一个 rect 的 width 推断
|
||
rects = re.findall(r'<rect x="(\d+)" y="(\d+)" width="(\d+)" height="(\d+)"/>', svg)
|
||
if not rects:
|
||
raise ValueError("no rects")
|
||
scale = int(rects[0][3]) # height = scale
|
||
size = total // scale
|
||
grid = [[1] * size for _ in range(size)] # 背景白=0,先全填 0 再置黑
|
||
grid = [[0] * size for _ in range(size)]
|
||
for x, y, w, h in rects:
|
||
x, y, w, h = int(x), int(y), int(w), int(h)
|
||
r0, c0 = y // scale, x // scale
|
||
for dc in range(w // scale):
|
||
if 0 <= r0 < size and c0 + dc < size:
|
||
grid[r0][c0 + dc] = 1
|
||
return grid
|
||
|
||
|
||
def _strip_border(grid: list) -> list:
|
||
"""去掉静默区,回到纯模块矩阵。"""
|
||
size = len(grid)
|
||
# 找第一行/列全 0 的边界(静默区)
|
||
def all_zero(values):
|
||
return all(v == 0 for v in values)
|
||
|
||
top = 0
|
||
while top < size and all_zero(grid[top]):
|
||
top += 1
|
||
left = 0
|
||
while left < size and all_zero([grid[r][left] for r in range(size)]):
|
||
left += 1
|
||
bottom = size - 1
|
||
while bottom >= 0 and all_zero(grid[bottom]):
|
||
bottom -= 1
|
||
right = size - 1
|
||
while right >= 0 and all_zero([grid[r][right] for r in range(size)]):
|
||
right -= 1
|
||
return [row[left:right + 1] for row in grid[top:bottom + 1]]
|
||
|
||
|
||
def _read_format(mat: list) -> tuple:
|
||
"""读左上格式信息 → (纠错级别, 掩码)。"""
|
||
size = len(mat)
|
||
seq = [(8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 7), (8, 8),
|
||
(7, 8), (5, 8), (4, 8), (3, 8), (2, 8), (1, 8), (0, 8)]
|
||
bits = 0
|
||
for i, (r, c) in enumerate(seq):
|
||
bits |= (mat[r][c] & 1) << i
|
||
bits ^= 0b101010000010010
|
||
ec = (bits >> 13) & 0b11
|
||
mask = (bits >> 10) & 0b111
|
||
return ec, mask
|
||
|
||
|
||
def _unmask(mat: list, mask: int) -> list:
|
||
"""按掩码规则还原数据区。"""
|
||
from .qr import _is_function
|
||
|
||
size = len(mat)
|
||
version = (size - 17) // 4
|
||
out = [row[:] for row in mat]
|
||
for r in range(size):
|
||
for c in range(size):
|
||
if _is_function(r, c, version):
|
||
continue
|
||
invert = False
|
||
if mask == 0:
|
||
invert = (r + c) % 2 == 0
|
||
elif mask == 1:
|
||
invert = r % 2 == 0
|
||
elif mask == 2:
|
||
invert = c % 3 == 0
|
||
elif mask == 3:
|
||
invert = (r + c) % 3 == 0
|
||
if invert:
|
||
out[r][c] ^= 1
|
||
return out
|
||
|
||
|
||
def _extract_bits(mat: list, version: int | None = None) -> list:
|
||
"""按之字形路径读出数据位(跳过功能模块)。"""
|
||
from .qr import _is_function
|
||
|
||
size = len(mat)
|
||
version = version if version is not None else (size - 17) // 4
|
||
bits = []
|
||
col = size - 1
|
||
upward = True
|
||
while col > 0:
|
||
if col == 6:
|
||
col -= 1
|
||
rows = range(size - 1, -1, -1) if upward else range(size)
|
||
for row in rows:
|
||
for c in (col, col - 1):
|
||
if not _is_function(row, c, version):
|
||
bits.append(mat[row][c] & 1)
|
||
upward = not upward
|
||
col -= 2
|
||
return bits
|
||
|
||
|
||
def _bits_to_bytes(bits: list) -> bytes:
|
||
out = bytearray()
|
||
for i in range(0, len(bits) - 7, 8):
|
||
b = 0
|
||
for j in range(8):
|
||
b = (b << 1) | bits[i + j]
|
||
out.append(b)
|
||
return bytes(out)
|
||
|
||
|
||
def decode(svg: str) -> str | None:
|
||
"""解码本模块生成的 QR SVG,返回原文;失败返回 None。
|
||
|
||
流程:SVG → 模块矩阵 → 去静默区 → 读格式信息(纠错/掩码) → 去掩码
|
||
→ 之字形读位 → 字节模式解析。
|
||
"""
|
||
from .qr import _is_function
|
||
|
||
grid = _svg_to_matrix(svg)
|
||
mat = _strip_border(grid)
|
||
size = len(mat)
|
||
version = (size - 17) // 4
|
||
if version not in VERSIONS_L:
|
||
return None
|
||
|
||
ec, mask = _read_format(mat)
|
||
if ec != 0b01: # 只支持纠错 L
|
||
return None
|
||
|
||
# 去掩码
|
||
base = [row[:] for row in mat]
|
||
for r in range(size):
|
||
for c in range(size):
|
||
if _is_function(r, c, version):
|
||
continue
|
||
if mask == 0 and (r + c) % 2 == 0:
|
||
base[r][c] ^= 1
|
||
elif mask == 1 and r % 2 == 0:
|
||
base[r][c] ^= 1
|
||
elif mask == 2 and c % 3 == 0:
|
||
base[r][c] ^= 1
|
||
elif mask == 3 and (r + c) % 3 == 0:
|
||
base[r][c] ^= 1
|
||
|
||
# 之字形读位
|
||
bits = []
|
||
col = size - 1
|
||
upward = True
|
||
while col > 0:
|
||
if col == 6:
|
||
col -= 1
|
||
rows = range(size - 1, -1, -1) if upward else range(size)
|
||
for row in rows:
|
||
for c in (col, col - 1):
|
||
if not _is_function(row, c, version):
|
||
bits.append(base[row][c] & 1)
|
||
upward = not upward
|
||
col -= 2
|
||
|
||
# 位流解析(注意:不能按字节边界切——模式 4 位 + 长度 8 位 = 12 位,
|
||
# 数据从第 12 位开始,跨字节。早期版本按字节取导致整体错位。)
|
||
if len(bits) < 12:
|
||
return None
|
||
if bits[0:4] != [0, 1, 0, 0]: # 字节模式
|
||
return None
|
||
length = 0
|
||
for b in bits[4:12]: # 版本 1-9:长度 8 位
|
||
length = (length << 1) | b
|
||
need = 12 + length * 8
|
||
if length <= 0 or len(bits) < need:
|
||
return None
|
||
payload = bytearray()
|
||
for i in range(length):
|
||
byte = 0
|
||
for j in range(8):
|
||
byte = (byte << 1) | bits[12 + i * 8 + j]
|
||
payload.append(byte)
|
||
try:
|
||
return payload.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
return None
|
||
|
||
|
||
def selftest() -> int:
|
||
"""往返测试若干载荷,返回失败数。"""
|
||
from .qr import qr_svg_data_uri
|
||
|
||
samples = [
|
||
"/api/v1/open/statements/9eb70e10-82e1-47d1-bc5b-fb3e0716ba19/",
|
||
"https://dealerhub.example.com/api/v1/open/statements/c71ac955-27d6-4942-968c-720a6afe5394/",
|
||
"HELLO-DEALERHUB-2026",
|
||
"1",
|
||
"a" * 60,
|
||
"对账单-中文测试",
|
||
]
|
||
failed = 0
|
||
for text in samples:
|
||
uri = qr_svg_data_uri(text, scale=4)
|
||
svg = base64.b64decode(uri.split(",", 1)[1]).decode("utf-8")
|
||
got = decode(svg)
|
||
ok = got == text
|
||
failed += 0 if ok else 1
|
||
mark = "✓" if ok else "✗"
|
||
print(f" {mark} len={len(text):3d} expect={text[:45]!r}"
|
||
+ ("" if ok else f" got={got!r}"))
|
||
print(f"\n往返测试:{len(samples) - failed}/{len(samples)} 通过")
|
||
return failed
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(1 if selftest() else 0)
|