From f455246fde086dda0cb61b92c84a5146a831a2bf Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 06:56:58 +0200 Subject: test(codes): compare QR with segno, decode QR dumps and the QR screenshot Co-Authored-By: Claude Opus 5.5 --- tools/gen_code_vectors.py | 110 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) (limited to 'tools/gen_code_vectors.py') diff --git a/tools/gen_code_vectors.py b/tools/gen_code_vectors.py index 970ca06..a87b0c9 100644 --- a/tools/gen_code_vectors.py +++ b/tools/gen_code_vectors.py @@ -15,10 +15,13 @@ from __future__ import annotations import argparse import random +import re import string from pathlib import Path +from typing import Any, cast import barcode_ref as ref +import segno CHECK_ADD, CHECK_FIX, CHECK_OFF = 0, 1, 2 TEXT_ON = 0x10 @@ -92,6 +95,94 @@ def cases(rng: random.Random, count: int) -> list[tuple[int, str, int, str]]: return out +QR_LEVELS = "LMQH" +LEVEL_CONSTS = [ + segno.consts.ERROR_LEVEL_L, + segno.consts.ERROR_LEVEL_M, + segno.consts.ERROR_LEVEL_Q, + segno.consts.ERROR_LEVEL_H, +] + + +def qr_cases(rng: random.Random, count: int) -> list[tuple[str, int, int, int]]: + """(payload, level 0-3, version 0 = auto, mask -1 = auto) for QR. + + Single-mode payloads only (numeric, alphanumeric or bytes), so segno's + one-segment encoding and the watch's optimal segmentation agree: a digit + run inside other text would be a cheaper numeric segment on the watch. + Mixed content is covered by the decoding check instead. + """ + fixed = [ + ("01234567", 1, 1, 2), + ("HELLO WORLD", 2, 1, 6), + ("hello, world", 0, 2, 0), + ("Zürich ✓ fake", 1, 5, 3), + ("https://example.com/member/abc", 1, 0, -1), + ("314159265358979323846264338327950288419716939937510", 3, 0, -1), + ("THE QUICK BROWN FOX 0123456789 $%*+-./:", 2, 7, 4), + ("v" * 120, 1, 10, 5), + ("Z" * 300, 0, 0, -1), + ("9" * 700, 1, 0, -1), + ("byte data " * 40, 3, 0, -1), + ] + out = list(fixed) + for _ in range(count): + kind = rng.randrange(3) + n = rng.randint(1, 60) + if kind == 0: + payload = digits(rng, n) + elif kind == 1: + payload = "".join(rng.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 $%*+-./:") for _ in range(n)) + if payload.isdigit(): + payload += "A" + else: + payload = "".join(rng.choice(string.ascii_lowercase + "!?,;") for _ in range(n)) + if not payload.isdigit() and re.search(r"\d{4,}", payload): + continue + out.append((payload, rng.randrange(4), 0, -1)) + return out + + +def single_mode_bits(payload: str, version: int) -> int: + """Bits of payload as one numeric, alphanumeric or byte segment.""" + c = 0 if version <= 9 else (1 if version <= 26 else 2) + if payload.isdigit(): + n = len(payload) + return 4 + (10, 12, 14)[c] + 10 * (n // 3) + (0, 4, 7)[n % 3] + if all(ch in "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" for ch in payload): + n = len(payload) + return 4 + (9, 11, 13)[c] + 11 * (n // 2) + 6 * (n % 2) + return 4 + (8, 16, 16)[c] + 8 * len(payload.encode()) + + +def segno_pads_extra_byte(payload: str, level: int, version: int) -> bool: + """True when segno would insert an extra zero byte: after the terminator + its write_padding_bits adds 8 - (length % 8) bits even when the stream + already ends on a codeword boundary (ISO/IEC 18004 7.4.10 adds none). + The watch follows the standard, so such payloads make no exact reference.""" + groups = cast(dict[int, dict[int, tuple[Any, ...]]], segno.consts.ECC)[version][LEVEL_CONSTS[level]] + capacity = 8 * sum(g.num_blocks * g.num_data for g in groups) + bits = single_mode_bits(payload, version) + terminated = bits + min(4, capacity - bits) + return bool(terminated < capacity and terminated % 8 == 0) + + +def qr_reference(payload: str, level: int, version: int, mask: int) -> tuple[int, int, str]: + """segno's (version, mask, matrix as hex of row-major bits) for the case.""" + qr = segno.make_qr( + payload, + error=QR_LEVELS[level], + version=version or None, + mask=None if mask < 0 else mask, + boost_error=False, + encoding="utf-8", + ) + bits = "".join(str(b) for row in qr.matrix for b in row) + bits += "0" * (-len(bits) % 8) + packed = bytes(int(bits[i : i + 8], 2) for i in range(0, len(bits), 8)) + return int(qr.designator.split("-")[0]), qr.mask, packed.hex() + + def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: lines = [] for symbology, payload, opts, text in rows: @@ -99,12 +190,29 @@ def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: escaped = payload.replace("\\", "\\\\").replace('"', '\\"') lines.append(f' [{symbology}, "{escaped}", {opts}, "{modules}"]') body = ",\n".join(lines) + qr_lines = [] + for case in qr_cases(random.Random(seed + 1), 20): + payload, level, version, mask = case + ref_version, ref_mask, hexbits = qr_reference(payload, level, version, mask) + while segno_pads_extra_byte(payload, level, ref_version): + # Lengthen by one character of the same kind until segno's padding + # quirk doesn't apply. + payload += payload[-1] + ref_version, ref_mask, hexbits = qr_reference(payload, level, version, mask) + escaped = payload.replace("\\", "\\\\").replace('"', '\\"') + qr_lines.append(f' ["{escaped}", {level}, {version}, {mask}, {ref_version}, {ref_mask}, "{hexbits}"]') + qr_body = ",\n".join(qr_lines) return ( "// Author: cirakg & claude ai\n" "// Summary: zxing-cpp reference module rows for the linear barcode encoders (generated; do not edit).\n" f"//\n// Generated by tools/gen_code_vectors.py, seed {seed}. Rows: [symbology, payload, options,\n" "// expected modules without quiet zones]. Fake payloads only.\n\n" - "import Toybox.Lang;\n\n(:test)\nmodule CodeVectors {\n const LINEAR = [\n" + body + "\n ];\n}\n" + "import Toybox.Lang;\n\n(:test)\nmodule CodeVectors {\n const LINEAR = [\n" + + body + + "\n ];\n\n // [payload, level, version (0 auto), mask (-1 auto), segno version, segno mask,\n" + + " // segno matrix as hex of row-major bits]\n const QR = [\n" + + qr_body + + "\n ];\n}\n" ) -- cgit v1.2.3