Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s

This commit is contained in:
ARIA committed 2026-08-22 22:43:13 +02:00
1 parent 27dc7917f2
commit 7a6d922d12
63 files changed
+2073 -630

No files matched your search

+494
View File
@@ -0,0 +1,494 @@
"""Pure-stdlib QR encoder (ISO/IEC 18004) + terminal renderer.
Scope is deliberately minimal — we only ever encode ASCII pairing URLs
(``iris://pair?...``):
- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths).
- **Error correction:** level **M** (15 %); auto-fallback to **L** if the
payload doesn't fit at M within the version cap.
- **Versions:** 1–10, auto-selected (smallest version whose capacity fits).
Payloads that don't fit v10-L raise :class:`QrTooLongError`.
No third-party imports (no ``qrcode``/``segno``/``Pillow``) — the plugin's
zero-new-dep rule. No I/O, no module-level mutable state, fully unit-testable.
Public API:
- :func:`qr_matrix` — encode *data* (ASCII) into a module matrix
(``True`` = dark) including the 4-module quiet zone.
- :func:`render_qr` — render *data* as a terminal QR using Unicode
half-blocks; returns ``""`` (not an exception) when the payload is too long.
"""
from __future__ import annotations
__all__ = ["QrTooLongError", "qr_matrix", "render_qr"]
class QrTooLongError(ValueError):
"""Raised when *data* doesn't fit in any supported version (1–10)."""
# ---------------------------------------------------------------------------
# GF(256) arithmetic (polynomial 0x11D)
# ---------------------------------------------------------------------------
_GF_EXP = [0] * 512
_GF_LOG = [0] * 256
_x = 1
for _i in range(255):
_GF_EXP[_i] = _x
_GF_LOG[_x] = _i
_x <<= 1
if _x & 0x100:
_x ^= 0x11D
for _i in range(255, 512):
_GF_EXP[_i] = _GF_EXP[_i - 255]
def _gf_mul(a: int, b: int) -> int:
if a == 0 or b == 0:
return 0
return _GF_EXP[_GF_LOG[a] + _GF_LOG[b]]
def _rs_generator_poly(degree: int) -> list[int]:
"""Generator polynomial of *degree* (big-endian, leading coeff first)."""
poly = [1]
for i in range(degree):
new = [0] * (len(poly) + 1)
for k, coef in enumerate(poly):
new[k] ^= coef # x * coef
new[k + 1] ^= _gf_mul(coef, _GF_EXP[i])
poly = new
return poly
def _rs_encode(data: list[int], ec_len: int) -> list[int]:
"""Reed–Solomon error-correction codewords for *data*."""
gen = _rs_generator_poly(ec_len)
buf = list(data) + [0] * ec_len
for i in range(len(data)):
coef = buf[i]
if coef:
for j in range(1, len(gen)):
buf[i + j] ^= _gf_mul(gen[j], coef)
return buf[len(data) :]
# ---------------------------------------------------------------------------
# Block structure (version, EC level) -> (ec_per_block, [(count, data_cw), ...])
#
# Source: ISO/IEC 18004 Table 9 (cross-checked against the reference encoder).
# Only levels L and M are needed (M primary, L fallback).
# ---------------------------------------------------------------------------
_BLOCK_TABLE: dict[tuple[int, str], tuple[int, list[tuple[int, int]]]] = {
(1, "L"): (7, [(1, 19)]),
(1, "M"): (10, [(1, 16)]),
(2, "L"): (10, [(1, 34)]),
(2, "M"): (16, [(1, 28)]),
(3, "L"): (15, [(1, 55)]),
(3, "M"): (26, [(1, 44)]),
(4, "L"): (20, [(1, 80)]),
(4, "M"): (18, [(2, 32)]),
(5, "L"): (26, [(1, 108)]),
(5, "M"): (24, [(2, 43)]),
(6, "L"): (18, [(2, 68)]),
(6, "M"): (16, [(4, 27)]),
(7, "L"): (20, [(2, 78)]),
(7, "M"): (18, [(4, 31)]),
(8, "L"): (24, [(2, 97)]),
(8, "M"): (22, [(2, 38), (2, 39)]),
(9, "L"): (30, [(2, 116)]),
(9, "M"): (22, [(3, 36), (2, 37)]),
(10, "L"): (18, [(2, 68), (2, 69)]),
(10, "M"): (26, [(4, 43), (1, 44)]),
}
# Alignment-pattern centre coordinates per version (v1 has none).
_ALIGNMENT: dict[int, list[int]] = {
1: [],
2: [6, 18],
3: [6, 22],
4: [6, 26],
5: [6, 30],
6: [6, 34],
7: [6, 22, 38],
8: [6, 24, 42],
9: [6, 26, 46],
10: [6, 28, 50],
}
# EC level -> 2-bit format-info code (ISO/IEC 18004 Table 17).
_EC_FORMAT_BITS = {"L": 0b01, "M": 0b00}
_MIN_VERSION, _MAX_VERSION = 1, 10
_QUIET = 4
def _data_capacity(version: int, level: str) -> int:
"""Max payload bytes in byte mode for (version, level)."""
_, groups = _BLOCK_TABLE[(version, level)]
data_bits = sum(count * data_cw for count, data_cw in groups) * 8
# mode indicator (4) + char count (8 for v1-9, 16 for v10) + terminator (4)
count_bits = 16 if version >= 10 else 8
return (data_bits - 4 - count_bits - 4) // 8
def _select_version(data: bytes) -> tuple[int, str]:
for level in ("M", "L"):
for version in range(_MIN_VERSION, _MAX_VERSION + 1):
if len(data) <= _data_capacity(version, level):
return version, level
raise QrTooLongError(f"payload of {len(data)} bytes exceeds v{_MAX_VERSION}-L capacity")
# ---------------------------------------------------------------------------
# Data encoding (byte mode)
# ---------------------------------------------------------------------------
def _encode_data(data: bytes, version: int, level: str) -> list[int]:
"""Return the full codeword stream (data + EC), interleaved per spec."""
_, groups = _BLOCK_TABLE[(version, level)]
ec_per_block = _BLOCK_TABLE[(version, level)][0]
total_data_cw = sum(count * data_cw for count, data_cw in groups)
bits: list[int] = []
def put(value: int, width: int) -> None:
for i in range(width - 1, -1, -1):
bits.append((value >> i) & 1)
put(0b0100, 4) # byte mode
put(len(data), 16 if version >= 10 else 8) # char count
for byte in data:
put(byte, 8)
# terminator (up to 4 zero bits)
capacity_bits = total_data_cw * 8
put(0, min(4, capacity_bits - len(bits)))
# pad to byte boundary
if len(bits) % 8:
put(0, 8 - len(bits) % 8)
# pad bytes 0xEC / 0x11
pad_bytes = [0xEC, 0x11]
pi = 0
while len(bits) < capacity_bits:
put(pad_bytes[pi % 2], 8)
pi += 1
data_cw = [int("".join(map(str, bits[i : i + 8])), 2) for i in range(0, len(bits), 8)]
# Split into blocks, compute EC per block.
blocks: list[list[int]] = []
ec_blocks: list[list[int]] = []
idx = 0
for count, data_cw_len in groups:
for _ in range(count):
block = data_cw[idx : idx + data_cw_len]
idx += data_cw_len
blocks.append(block)
ec_blocks.append(_rs_encode(block, ec_per_block))
# Interleave data codewords, then EC codewords (ISO/IEC 18004 §8.6.3).
out: list[int] = []
max_data = max(len(b) for b in blocks)
for i in range(max_data):
for b in blocks:
if i < len(b):
out.append(b[i])
max_ec = max(len(b) for b in ec_blocks)
for i in range(max_ec):
for b in ec_blocks:
if i < len(b):
out.append(b[i])
return out
# ---------------------------------------------------------------------------
# Matrix construction
# ---------------------------------------------------------------------------
def _bch(data: int, shift: int, generator: int) -> int:
"""BCH codeword: *data* shifted left by *shift*, the low *shift* bits
filled with the remainder of the division by *generator*."""
d = data << shift
g_len = generator.bit_length()
while d.bit_length() >= g_len:
d ^= generator << (d.bit_length() - g_len)
return (data << shift) | d
def _format_info(level: str, mask: int) -> int:
"""15-bit format info (BCH(15,5)) XORed with 0x5412."""
data = (_EC_FORMAT_BITS[level] << 3) | mask
return _bch(data, 10, 0x537) ^ 0x5412
def _version_info(version: int) -> int:
"""18-bit version info (BCH(18,6)); only for v7+."""
return _bch(version, 12, 0x1F25)
def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]:
size = 17 + 4 * version
# matrix[r][c] = dark; reserved[r][c] = function module (not data)
matrix = [[False] * size for _ in range(size)]
reserved = [[False] * size for _ in range(size)]
def set_module(r: int, c: int, dark: bool) -> None:
matrix[r][c] = dark
reserved[r][c] = True
# Finder patterns + separators (three corners).
for fr, fc in ((0, 0), (0, size - 7), (size - 7, 0)):
for r in range(-1, 8):
for c in range(-1, 8):
rr, cc = fr + r, fc + c
if not (0 <= rr < size and 0 <= cc < size):
continue
if 0 <= r <= 6 and 0 <= c <= 6:
# Canonical finder: 7x7 border dark, 5x5 white, 3x3 dark centre.
ring = max(abs(r - 3), abs(c - 3))
set_module(rr, cc, ring in (0, 1, 3))
else:
set_module(rr, cc, False) # separator
# Timing patterns.
for i in range(8, size - 8):
dark = i % 2 == 0
if not reserved[6][i]:
set_module(6, i, dark)
if not reserved[i][6]:
set_module(i, 6, dark)
# Alignment patterns (v2+), skipping those overlapping finders.
positions = _ALIGNMENT[version]
if len(positions) > 1:
for r in positions:
for c in positions:
# Skip the three corners that share a finder pattern.
if (
(r == positions[0] and c == positions[0])
or (r == positions[0] and c == positions[-1])
or (r == positions[-1] and c == positions[0])
):
continue
for dr in range(-2, 3):
for dc in range(-2, 3):
ring = max(abs(dr), abs(dc))
dark = ring != 1
set_module(r + dr, c + dc, dark)
# Dark module (always dark) at (4*version + 9, 8).
set_module(4 * version + 9, 8, True)
# Reserve format-info regions (filled after masking).
for i in range(9):
if not reserved[8][i]:
reserved[8][i] = True
if not reserved[i][8]:
reserved[i][8] = True
for i in range(8):
reserved[8][size - 1 - i] = True
reserved[size - 1 - i][8] = True
# (8,8) handled above; mark the remaining format cells.
reserved[8][8] = True
# Reserve version-info regions (v7+).
if version >= 7:
vinfo = _version_info(version)
for i in range(18):
bit = (vinfo >> i) & 1
# Two 3x6 blocks: top-left and bottom-right corners.
r, c = size - 11 + (i % 3), i // 3
set_module(r, c, bool(bit))
r, c = i // 3, size - 11 + (i % 3)
set_module(r, c, bool(bit))
# Place data codewords in the zig-zag, applying the mask. Start at the
# bottom-right and traverse column pairs bottom-to-top, then top-to-bottom.
bit_index = 0
total_bits = len(codewords) * 8
inc = -1
row = size - 1
for col in range(size - 1, 0, -2):
if col <= 6:
col -= 1 # skip the vertical timing column
while True:
for c in (col, col - 1):
if not reserved[row][c]:
bit = 0
if bit_index < total_bits:
bit = (codewords[bit_index // 8] >> (7 - bit_index % 8)) & 1
bit_index += 1
if _mask_bit(mask, row, c):
bit ^= 1
matrix[row][c] = bool(bit)
row += inc
if row < 0 or row >= size:
row -= inc
inc = -inc
break
# Write format info (after masking, unmasked).
fmt = _format_info(level, mask)
for i in range(15):
bit = bool((fmt >> i) & 1)
# Vertical copy (column 8).
if i < 6:
set_module(i, 8, bit)
elif i < 8:
set_module(i + 1, 8, bit)
else:
set_module(size - 15 + i, 8, bit)
# Horizontal copy (row 8).
if i < 8:
set_module(8, size - i - 1, bit)
elif i < 9:
set_module(8, 15 - i, bit)
else:
set_module(8, 15 - i - 1, bit)
return matrix
def _mask_bit(mask: int, r: int, c: int) -> bool:
if mask == 0:
return (r + c) % 2 == 0
if mask == 1:
return r % 2 == 0
if mask == 2:
return c % 3 == 0
if mask == 3:
return (r + c) % 3 == 0
if mask == 4:
return (r // 2 + c // 3) % 2 == 0
if mask == 5:
return (r * c) % 2 + (r * c) % 3 == 0
if mask == 6:
return ((r * c) % 2 + (r * c) % 3) % 2 == 0
if mask == 7:
return ((r + c) % 2 + (r * c) % 3) % 2 == 0
raise ValueError(f"invalid mask {mask}")
# ---------------------------------------------------------------------------
# Penalty scoring (ISO/IEC 18004 §8.8.2)
# ---------------------------------------------------------------------------
def _penalty(matrix: list[list[bool]]) -> int:
size = len(matrix)
total = 0
# N1: runs of >= 5 same-colour in rows and columns.
for line in _all_lines(matrix):
run = 1
for i in range(1, len(line)):
if line[i] == line[i - 1]:
run += 1
else:
if run >= 5:
total += 3 + (run - 5)
run = 1
if run >= 5:
total += 3 + (run - 5)
# N2: 2x2 blocks of same colour.
for r in range(size - 1):
for c in range(size - 1):
v = matrix[r][c]
if v == matrix[r][c + 1] == matrix[r + 1][c] == matrix[r + 1][c + 1]:
total += 3
# N3: 10111010000 / 00001011101 patterns (with 4 light on one side).
pattern_a = [True, False, True, True, True, False, True, False, False, False, False]
pattern_b = [False, False, False, False, True, False, True, True, True, False, True]
for line in _all_lines(matrix):
for i in range(len(line) - 10):
window = line[i : i + 11]
if window in (pattern_a, pattern_b):
total += 40
# N4: dark/light balance (integer math: floor(|percent - 50| / 5) * 10).
dark = sum(cell for line in matrix for cell in line)
total += 10 * (abs(20 * dark - 10 * size * size) // (5 * size * size))
return total
def _all_lines(matrix: list[list[bool]]):
size = len(matrix)
for r in range(size):
yield matrix[r]
for c in range(size):
yield [matrix[r][c] for r in range(size)]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def qr_matrix(data: str) -> list[list[bool]]:
"""Encode *data* (ASCII) into a module matrix (``True`` = dark).
Includes the 4-module quiet zone. Raises :class:`QrTooLongError` when the
payload doesn't fit in versions 1–10.
"""
payload = data.encode("ascii")
version, level = _select_version(payload)
codewords = _encode_data(payload, version, level)
best = _build_matrix(version, level, codewords, 0)
best_penalty = _penalty(best)
for mask in range(1, 8):
m = _build_matrix(version, level, codewords, mask)
p = _penalty(m)
if p < best_penalty:
best, best_penalty = m, p
size = len(best)
return (
[[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
+ [[False] * _QUIET + row + [False] * _QUIET for row in best]
+ [[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
)
def render_qr(data: str) -> str:
"""Render *data* as a terminal QR using Unicode half-blocks.
Returns ``""`` (not an exception) when the payload is too long. Pair
consecutive module rows into one character row: both dark → ``█``, top
dark → ``▀``, bottom dark → ``▄``, both light → space. No ANSI colours or
cursor tricks — survives ``less``, log files, and copy-paste.
"""
try:
matrix = qr_matrix(data)
except QrTooLongError:
return ""
height = len(matrix)
width = len(matrix[0])
if height % 2:
matrix = matrix + [[False] * width]
lines: list[str] = []
for r in range(0, len(matrix), 2):
chars: list[str] = []
for c in range(width):
top, bottom = matrix[r][c], matrix[r + 1][c]
if top and bottom:
chars.append("█")
elif top:
chars.append("▀")
elif bottom:
chars.append("▄")
else:
chars.append(" ")
lines.append("".join(chars))
return "\n".join(lines)