aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/barcode_ref.py21
-rw-r--r--tools/check_codes.py16
-rw-r--r--tools/gen_code_vectors.py146
-rw-r--r--tools/gen_pdf417_tables.py77
-rw-r--r--tools/tests/test_barcode_tools.py26
-rw-r--r--tools/ui_flow.py19
6 files changed, 292 insertions, 13 deletions
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' <jsonData id="Pdf417{name.capitalize()}" filename="{name}.json" />' for name in names)
+ return (
+ "<!--\n Author: cirakg & claude ai\n"
+ " Summary: The PDF417 tables as JSON resources, loaded only while a PDF417 code is encoded (generated).\n-->\n"
+ f"<resources>\n{rows}\n</resources>\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: