From 1251aab9f8f9f38aa5bdefd4fed1f58236166cf8 Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 11:24:58 +0200 Subject: test(codes): compare Data Matrix and PDF417 with references and decode them, also from screenshots Co-Authored-By: Claude Opus 5.5 --- tools/barcode_ref.py | 21 +++--- tools/check_codes.py | 16 ++++- tools/gen_code_vectors.py | 146 ++++++++++++++++++++++++++++++++++++++ tools/gen_pdf417_tables.py | 77 ++++++++++++++++++++ tools/tests/test_barcode_tools.py | 26 ++++++- tools/ui_flow.py | 19 ++++- 6 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 tools/gen_pdf417_tables.py (limited to 'tools') diff --git a/tools/barcode_ref.py b/tools/barcode_ref.py index f56a7c3..9c4c6d5 100644 --- a/tools/barcode_ref.py +++ b/tools/barcode_ref.py @@ -14,9 +14,12 @@ from typing import Any, cast import zxingcpp from PIL import Image -QR, CODE128, CODE39, EAN13, EAN8, UPCA, UPCE, ITF, CODABAR = range(9) +QR, CODE128, CODE39, EAN13, EAN8, UPCA, UPCE, ITF, CODABAR, DATAMATRIX, PDF417, AZTEC = range(12) FORMATS = { QR: zxingcpp.BarcodeFormat.QRCode, + DATAMATRIX: zxingcpp.BarcodeFormat.DataMatrix, + PDF417: zxingcpp.BarcodeFormat.PDF417, + AZTEC: zxingcpp.BarcodeFormat.Aztec, CODE128: zxingcpp.BarcodeFormat.Code128, CODE39: zxingcpp.BarcodeFormat.Code39, EAN13: zxingcpp.BarcodeFormat.EAN13, @@ -55,15 +58,17 @@ def render_linear(modules: str, scale: int, height: int = 40) -> Image.Image: READ_FORMATS = {**FORMATS, CODE39: zxingcpp.BarcodeFormat.Code39Std, UPCA: zxingcpp.BarcodeFormat.EAN13} -def render_matrix(packed: bytes, size: int, scale: int, quiet: int) -> Image.Image: - """A square matrix (row-major bits, 8 per byte) as black modules on white with a quiet zone.""" - side = (size + 2 * quiet) * scale - image = Image.new("L", (side, side), 255) - for i in range(size * size): +def render_matrix(packed: bytes, shape: tuple[int, int], scale: int, *, quiet: int, row_height: int = 1) -> Image.Image: + """A matrix (row-major bits, 8 per byte; shape = width and stored rows, + each row row_height modules high) as black modules on white with a + quiet zone.""" + size, rows = shape + image = Image.new("L", ((size + 2 * quiet) * scale, (rows * row_height + 2 * quiet) * scale), 255) + for i in range(size * rows): if packed[i // 8] & (0x80 >> (i % 8)): x, y = i % size, i // size - left, top = (quiet + x) * scale, (quiet + y) * scale - image.paste(0, (left, top, left + scale, top + scale)) + left, top = (quiet + x) * scale, (quiet + y * row_height) * scale + image.paste(0, (left, top, left + scale, top + scale * row_height)) return image diff --git a/tools/check_codes.py b/tools/check_codes.py index 53e30f0..7ec47b4 100644 --- a/tools/check_codes.py +++ b/tools/check_codes.py @@ -20,6 +20,8 @@ import barcode_ref as ref DUMP = re.compile(r"CODEDUMP (\d+) ([0-9a-f]*) ([01]+)") QR_DUMP = re.compile(r"QRDUMP (\d+) ([0-9a-f]*) ([0-9a-f]+)") +# Any 2D code: symbology, width, stored rows, row height, quiet zone, text as hex, modules as hex. +MATRIX_DUMP = re.compile(r"MATRIXDUMP (\d+) (\d+) (\d+) (\d+) (\d+) ([0-9a-f]*) ([0-9a-f]+)") SCALES = (1, 2, 3) QR_SCALES = (2, 3) # README 12.2: 2D codes use at least 2 px per module @@ -43,10 +45,22 @@ def check_log(path: Path) -> tuple[int, list[str]]: text = bytes.fromhex(match.group(2)).decode("utf-8") packed = bytes.fromhex(match.group(3)) for scale in QR_SCALES: - got = ref.decode(ref.render_matrix(packed, size, scale, quiet=4), ref.QR) + got = ref.decode(ref.render_matrix(packed, (size, size), scale, quiet=4), ref.QR) if text not in got: failures.append(f"QR size {size} '{text[:30]}' at {scale} px/module: read {got}") checked += 1 + for match in MATRIX_DUMP.finditer(path.read_text(encoding="utf-8", errors="replace")): + symbology, width, height, row_height, quiet = (int(match.group(k)) for k in range(1, 6)) + text = bytes.fromhex(match.group(6)).decode("utf-8") + packed = bytes.fromhex(match.group(7)) + for scale in QR_SCALES: + image = ref.render_matrix(packed, (width, height), scale, quiet=max(quiet, 2), row_height=row_height) + got = ref.decode(image, symbology) + if text not in got: + failures.append( + f"symbology {symbology} {width}x{height} '{text[:30]}' at {scale} px/module: read {got}" + ) + checked += 1 return checked, failures diff --git a/tools/gen_code_vectors.py b/tools/gen_code_vectors.py index a87b0c9..5ce2611 100644 --- a/tools/gen_code_vectors.py +++ b/tools/gen_code_vectors.py @@ -21,7 +21,10 @@ from pathlib import Path from typing import Any, cast import barcode_ref as ref +import ppf.datamatrix.codec_ascii # noqa: F401 (registers the datamatrix.ascii codec) import segno +from pdf417gen import encode as pdf417_encode +from ppf.datamatrix import DataMatrix CHECK_ADD, CHECK_FIX, CHECK_OFF = 0, 1, 2 TEXT_ON = 0x10 @@ -183,6 +186,126 @@ def qr_reference(payload: str, level: int, version: int, mask: int) -> tuple[int return int(qr.designator.split("-")[0]), qr.mask, packed.hex() +def dm_cases(rng: random.Random, count: int) -> list[str]: + """ASCII payloads for Data Matrix (ppf.datamatrix has no Base256 or + extended ASCII; those are covered by decoding). Lengths cover small, + multi-region and the largest (144 x 144, multi-block) sizes.""" + fixed = ["a", "123456", "Hello, World!", "0" * 44, "ABC-0042/fake member", "9" * 300, "x" * 500, "7" * 3116] + printable = string.ascii_letters + string.digits + " .,:;-/+()" + out = list(fixed) + for _ in range(count): + n = rng.choice([rng.randint(1, 40), rng.randint(40, 400)]) + out.append("".join(rng.choice(printable) for _ in range(n))) + return out + + +# Data codewords per square Data Matrix size (ISO/IEC 16022 table 7). +DM_CAPACITY = { + 10: 3, + 12: 5, + 14: 8, + 16: 12, + 18: 18, + 20: 22, + 22: 30, + 24: 36, + 26: 44, + 32: 62, + 36: 86, + 40: 114, + 44: 144, + 48: 174, + 52: 204, + 64: 280, + 72: 368, + 80: 456, + 88: 576, + 96: 696, + 104: 816, + 120: 1050, + 132: 1304, + 144: 1558, +} + + +def ppf_pad_differs(payload: str, size: int) -> bool: + """True when ppf.datamatrix would write a pad codeword of 0 instead of 254. + + ppf computes the 253-state pad as (R + 130) % 254, which turns 254 into 0; + ISO/IEC 16022 5.2.3 only subtracts 254 above 254. The watch follows the + standard, so such payloads make no exact reference. + """ + used = len(payload.encode("datamatrix.ascii")) + capacity = DM_CAPACITY[size] + return any((149 * pos) % 253 == 124 for pos in range(used + 2, capacity + 1)) + + +def dm_reference(payload: str) -> tuple[int, str]: + """ppf.datamatrix's (size, matrix as hex of row-major bits), ASCII only.""" + matrix = DataMatrix(payload, codecs=["ascii"]).matrix + bits = "".join(str(b) for row in 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 len(matrix), packed.hex() + + +def pdf417_cases(rng: random.Random, count: int) -> list[tuple[str, int, int]]: + """(payload, columns, level): text in all submodes, digit runs below and + above 13, UTF-8 bytes, exact 6-byte groups, levels 0-6, 1-12 columns.""" + fixed = [ + ("Hello, World!", 3, 2), + ("abcABC123;:@#", 4, 1), + ("Member 0042 - fake card", 2, 2), + ("12345678901234567890123456789012345678901234567890", 3, 3), + ("Zürich ✓ fake data", 4, 2), + ("\x01\x02\x03\x04\x05\x06", 2, 0), + ("tab\there\nnew line\r end", 5, 4), + ("x" * 200, 8, 5), + ("0" * 13 + "abc" + "1" * 12, 3, 2), + ("https://example.com/member/fake?id=0042&level=gold", 6, 6), + ] + out = list(fixed) + printable = string.ascii_letters + string.digits + " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + for _ in range(count): + n = rng.randint(1, 120) + payload = "".join(rng.choice(printable) if rng.random() < 0.8 else rng.choice("éßü€") for _ in range(n)) + out.append((payload, rng.randint(1, 12), rng.randint(0, 5))) + return out + + +def pdf417_reference(payload: str, columns: int, level: int) -> tuple[int, int, str] | None: + """pdf417gen's (rows, width, rows of modules as hex), or None when the + combination is outside PDF417's limits (fewer than 3 or more than 90 rows).""" + try: + codes = pdf417_encode(payload, columns=columns, security_level=level) + except ValueError: + return None + rows = ["".join(format(value, "b") for value in row) for row in codes] + bits = "".join(rows) + bits += "0" * (-len(bits) % 8) + packed = bytes(int(bits[i : i + 8], 2) for i in range(0, len(bits), 8)) + return len(rows), len(rows[0]), packed.hex() + + +def mc_string(text: str) -> str: + """A Monkey C string literal body for text (quotes, backslashes, controls escaped).""" + out = [] + for ch in text: + if ch in '\\"': + out.append("\\" + ch) + elif ch == "\n": + out.append("\\n") + elif ch == "\t": + out.append("\\t") + elif ch == "\r": + out.append("\\r") + elif ord(ch) < 32: + out.append(f"\\u{ord(ch):04x}") + else: + out.append(ch) + return "".join(out) + + def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: lines = [] for symbology, payload, opts, text in rows: @@ -202,6 +325,23 @@ def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: escaped = payload.replace("\\", "\\\\").replace('"', '\\"') qr_lines.append(f' ["{escaped}", {level}, {version}, {mask}, {ref_version}, {ref_mask}, "{hexbits}"]') qr_body = ",\n".join(qr_lines) + dm_lines = [] + for dm_case in dm_cases(random.Random(seed + 2), 20): + payload = dm_case + size, hexbits = dm_reference(payload) + while ppf_pad_differs(payload, size): + payload += payload[-1] + size, hexbits = dm_reference(payload) + dm_lines.append(f' ["{payload}", {size}, "{hexbits}"]') + dm_body = ",\n".join(dm_lines) + pdf_lines = [] + for payload, columns, level in pdf417_cases(random.Random(seed + 3), 20): + reference = pdf417_reference(payload, columns, level) + if reference is None or any(ord(ch) < 32 and ch not in "\t\n\r" for ch in payload): + continue + n_rows, width, hexbits = reference + pdf_lines.append(f' ["{mc_string(payload)}", {columns}, {level}, {n_rows}, {width}, "{hexbits}"]') + pdf_body = ",\n".join(pdf_lines) return ( "// Author: cirakg & claude ai\n" "// Summary: zxing-cpp reference module rows for the linear barcode encoders (generated; do not edit).\n" @@ -212,6 +352,12 @@ def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: + "\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 // [payload, ppf.datamatrix size, its matrix as hex of row-major bits] (ASCII mode)\n" + + " const DATAMATRIX = [\n" + + dm_body + + "\n ];\n\n // [payload, columns, level, pdf417gen rows, width, one module row per PDF417 row as hex]\n" + + " const PDF417 = [\n" + + pdf_body + "\n ];\n}\n" ) diff --git a/tools/gen_pdf417_tables.py b/tools/gen_pdf417_tables.py new file mode 100644 index 0000000..7a66677 --- /dev/null +++ b/tools/gen_pdf417_tables.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Generate the PDF417 table resources (patterns, EC factors, text table) from pdf417gen's published tables. +"""PDF417 tables for the watch (ISO/IEC 15438), taken from pdf417gen so the +2,787 bar patterns aren't typed by hand. Five JSON resources (the compiler +warns about resources over 8 KB): + +- codes0, codes1, codes2: the 17-module pattern of every codeword (0-928) + in clusters 0, 3 and 6 +- ec: the Reed-Solomon factors (mod 929) for levels 0-8 +- text: per text-compaction submode (upper, lower, mixed, punctuation), the + value of ASCII 0-126 in that submode, or -1 + +The app loads them only while it encodes a PDF417 code. + + gen_pdf417_tables.py [--out-dir resources/pdf417] +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from pdf417gen import codes, data + +SUBMODES = [data.UPPER, data.LOWER, data.MIXED, data.PUNCT] + + +HEADER = {"_author": "cirakg & claude ai"} + + +def tables() -> dict[str, object]: + """Resource name -> JSON content.""" + text = [[data.CHARACTERS_LOOKUP.get(c, {}).get(mode, -1) for c in range(127)] for mode in SUBMODES] + out: dict[str, object] = {} + for k in range(3): + out[f"codes{k}"] = { + **HEADER, + "_summary": f"PDF417 cluster {3 * k} patterns (generated; do not edit).", + "v": list(codes.CODES[k]), + } + out["ec"] = { + **HEADER, + "_summary": "PDF417 EC factors per level (generated; do not edit).", + "v": [list(f) for f in data.ERROR_CORRECTION_FACTORS], + } + out["text"] = {**HEADER, "_summary": "PDF417 text submode values (generated; do not edit).", "v": text} + return out + + +def resources_xml(names: list[str]) -> str: + rows = "\n".join(f' ' for name in names) + return ( + "\n" + f"\n{rows}\n\n" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out-dir", type=Path, default=Path("resources/pdf417")) + args = parser.parse_args(argv) + args.out_dir.mkdir(parents=True, exist_ok=True) + for old in args.out_dir.glob("*.json"): + old.unlink() + content = tables() + for name, value in content.items(): + (args.out_dir / f"{name}.json").write_text(json.dumps(value, separators=(",", ":")) + "\n", encoding="utf-8") + (args.out_dir / "pdf417.xml").write_text(resources_xml(list(content)), encoding="utf-8") + print(f"wrote {len(content)} PDF417 table resources to {args.out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_barcode_tools.py b/tools/tests/test_barcode_tools.py index 0ee3cc4..5a2e999 100644 --- a/tools/tests/test_barcode_tools.py +++ b/tools/tests/test_barcode_tools.py @@ -8,6 +8,7 @@ from pathlib import Path import barcode_ref as ref import check_codes import gen_code_vectors +import gen_pdf417_tables import gen_qr_tables ROOT = Path(__file__).resolve().parents[2] @@ -83,5 +84,28 @@ def test_segno_padding_quirk_detection() -> None: def test_rendered_qr_matrix_decodes() -> None: _, _, hexbits = gen_code_vectors.qr_reference("HELLO WORLD", 1, 1, 2) - image = ref.render_matrix(bytes.fromhex(hexbits), 21, 2, quiet=4) + image = ref.render_matrix(bytes.fromhex(hexbits), (21, 21), 2, quiet=4) assert ref.decode(image, ref.QR) == ["HELLO WORLD"] + + +def test_committed_pdf417_tables_match_pdf417gen(tmp_path: Path) -> None: + gen_pdf417_tables.main(["--out-dir", str(tmp_path)]) + for path in tmp_path.iterdir(): + assert path.read_text(encoding="utf-8") == (ROOT / "resources" / "pdf417" / path.name).read_text( + encoding="utf-8" + ) + codes = gen_pdf417_tables.tables() + assert len(codes["codes0"]["v"]) == 929 # type: ignore[index] + + +def test_ppf_padding_quirk_detection() -> None: + # (149 * pos) % 253 == 124 at pos 124 * 149^-1 mod 253; a 10x10 symbol + # (3 data codewords) has no pad at such a position. + assert not gen_code_vectors.ppf_pad_differs("a", 10) + assert any(gen_code_vectors.ppf_pad_differs("x" * n, 88) for n in (480, 500, 520)) + + +def test_pdf417_reference_rows() -> None: + reference = gen_code_vectors.pdf417_reference("Hello, World!", 3, 2) + assert reference is not None and reference[0] == 7 and reference[1] == 17 * (3 + 4) + 1 + assert gen_code_vectors.pdf417_reference("ab", 30, 0) is None # one row: below the minimum of 3 diff --git a/tools/ui_flow.py b/tools/ui_flow.py index 5428509..eea3e17 100644 --- a/tools/ui_flow.py +++ b/tools/ui_flow.py @@ -63,6 +63,8 @@ GYM_CARD = "5901234123457" # source/app/Fixtures.mc: EAN-13 with its check digi MEMBER_QR = ( # source/app/Fixtures.mc: a version 6 QR "https://example.com/member/fake-0042?name=Alice%20Example&card=0000111122223333&valid=2027-12-31&level=gold" ) +PARCEL_DM = "PARCEL-FAKE-0042-7731" # source/app/Fixtures.mc: Data Matrix +TICKET_PDF417 = "Boarding pass FAKE 0042 seat 12A" # source/app/Fixtures.mc: PDF417 QR_TIMEOUT_S = 30.0 # the QR is encoded in timer steps the first time @@ -430,7 +432,8 @@ def flow_lockout(f: Flow) -> None: def flow_code(f: Flow) -> None: - """The EAN-13 gym card (index 6) and the QR card (7): their screenshots must decode.""" + """The EAN-13 gym card (index 6), the QR (7), Data Matrix (8) and PDF417 (9) + cards: their screenshots must decode.""" f.act() f.back() # leave the waiting PIN screen f.expect("list") @@ -448,10 +451,20 @@ def flow_code(f: Flow) -> None: f.act() f.open_focused_row() f.expect("code", QR_TIMEOUT_S) - encoded = f.log.latest(r"DIAG qr_encoded version=(\d+) ms=(\d+)") + encoded = f.log.latest(r"DIAG matrix_encoded type=\d+ size=(\S+) ms=(\d+)") if encoded is not None: - print(f"[{f.sim.device_dir.name}] QR version {encoded.group(1)} encoded in {encoded.group(2)} ms") + print(f"[{f.sim.device_dir.name}] QR {encoded.group(1)} encoded in {encoded.group(2)} ms") f.shot("code_qr", expect_code=MEMBER_QR) + # Data Matrix (8) and PDF417 (9), also encoded as jobs the first time. + for name, expected in (("code_datamatrix", PARCEL_DM), ("code_pdf417", TICKET_PDF417)): + f.act() + f.back() + f.expect("list") + f.next() + f.act() + f.open_focused_row() + f.expect("code", QR_TIMEOUT_S) + f.shot(name, expect_code=expected) def run_flow(f: Flow) -> None: -- cgit v1.2.3