Files

237 lines
7.4 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 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)