caudal/programa/cartera/qr.py
esetheyers 4b158e662d Caudal 0.3.0
Link another Caudal (phone, USB) with a code or a QR instead of the password, choosing the server's root certificate as a file; read Bitcoin straight from Bitcoin Core or Knots, without an Electrum server; Android app (beta) and a Windows build script. Fixed: Save did nothing for expenses, refunds and dividends entered by hand.
2026-10-16 18:12:09 -05:00

237 lines
9.1 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.

#!/usr/bin/env python3
"""
qr.py — Dibuja un código QR sin depender de nada: del texto a la cuadrícula
de módulos oscuros y claros.
Solo lo que Caudal necesita: texto como bytes (UTF-8), nivel de corrección M
y versiones 1 a 12 (hasta 287 bytes), que da de sobra para una dirección y un
código de enlace. Sigue ISO/IEC 18004: Reed-Solomon sobre GF(256), bloques
entrelazados, las ocho máscaras y la de menor penalización.
matriz("hola") → lista de filas, cada una una lista de bool (True = oscuro)
"""
from __future__ import annotations
# Versión → (codewords de corrección por bloque, [(bloques, codewords de datos por bloque), …]) para el nivel M
_BLOQUES = {
1: (10, [(1, 16)]), 2: (16, [(1, 28)]), 3: (26, [(1, 44)]), 4: (18, [(2, 32)]),
5: (24, [(2, 43)]), 6: (16, [(4, 27)]), 7: (18, [(4, 31)]), 8: (22, [(2, 38), (2, 39)]),
9: (22, [(3, 36), (2, 37)]), 10: (26, [(4, 43), (1, 44)]), 11: (30, [(1, 50), (4, 51)]),
12: (22, [(6, 36), (2, 37)]),
}
_ALINEACION = {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], 11: [6, 30, 54], 12: [6, 32, 58]}
_MASCARAS = (
lambda x, y: (x + y) % 2 == 0, lambda x, y: y % 2 == 0, lambda x, y: x % 3 == 0, lambda x, y: (x + y) % 3 == 0,
lambda x, y: (x // 3 + y // 2) % 2 == 0, lambda x, y: x * y % 2 + x * y % 3 == 0,
lambda x, y: (x * y % 2 + x * y % 3) % 2 == 0, lambda x, y: ((x + y) % 2 + x * y % 3) % 2 == 0,
)
class TextoDemasiadoLargo(ValueError):
pass
def capacidad(version: int) -> int:
"""Bytes de texto que caben en esa versión."""
datos = sum(n * d for n, d in _BLOQUES[version][1])
return datos - (2 if version < 10 else 3) # 4 bits de modo + 8 o 16 de longitud, redondeado
# ── Reed-Solomon en GF(256), polinomio 0x11D ──
_EXP = [0] * 512
_LOG = [0] * 256
_x = 1
for _i in range(255):
_EXP[_i] = _x
_LOG[_x] = _i
_x <<= 1
if _x & 0x100:
_x ^= 0x11D
for _i in range(255, 512):
_EXP[_i] = _EXP[_i - 255]
def _mul(a: int, b: int) -> int:
return 0 if a == 0 or b == 0 else _EXP[_LOG[a] + _LOG[b]]
def _generador(grado: int) -> list[int]:
g = [1]
for i in range(grado):
g = [(_mul(g[j], _EXP[i]) if j < len(g) else 0) ^ (g[j - 1] if j else 0) for j in range(len(g) + 1)]
return g[::-1] # de mayor a menor grado, mónico
def _correccion(datos: list[int], grado: int) -> list[int]:
g = _generador(grado)
resto = [0] * grado
for b in datos:
factor = b ^ resto[0]
resto = resto[1:] + [0]
for i in range(grado):
resto[i] ^= _mul(g[i + 1], factor)
return resto
def _codewords(texto: bytes, version: int) -> list[int]:
ec, grupos = _BLOQUES[version]
total_datos = sum(n * d for n, d in grupos)
bits: list[int] = []
def mete(valor: int, n: int) -> None:
bits.extend((valor >> i) & 1 for i in range(n - 1, -1, -1))
mete(0b0100, 4)
mete(len(texto), 8 if version < 10 else 16)
for b in texto:
mete(b, 8)
mete(0, min(4, total_datos * 8 - len(bits)))
mete(0, -len(bits) % 8)
datos = [int("".join(map(str, bits[i:i + 8])), 2) for i in range(0, len(bits), 8)]
for relleno in (0xEC, 0x11) * total_datos:
if len(datos) >= total_datos:
break
datos.append(relleno)
bloques, pos = [], 0
for n, largo in grupos:
for _ in range(n):
bloques.append(datos[pos:pos + largo])
pos += largo
correcciones = [_correccion(b, ec) for b in bloques]
salida = [b[i] for i in range(max(map(len, bloques))) for b in bloques if i < len(b)]
salida += [c[i] for i in range(ec) for c in correcciones]
return salida
def _bch(valor: int, bits: int, generador: int) -> int:
resto = valor
for _ in range(bits):
resto = (resto << 1) ^ ((resto >> (generador.bit_length() - 2)) * generador)
return resto
def _base(version: int) -> tuple[list[list[bool]], list[list[bool]]]:
"""La cuadrícula con los patrones fijos y cuáles de sus módulos están reservados."""
n = 17 + 4 * version
m = [[False] * n for _ in range(n)]
fijo = [[False] * n for _ in range(n)]
def pon(x: int, y: int, oscuro: bool) -> None:
if 0 <= x < n and 0 <= y < n:
m[y][x] = oscuro
fijo[y][x] = True
for i in range(n): # líneas de sincronía
pon(6, i, i % 2 == 0)
pon(i, 6, i % 2 == 0)
for cx, cy in ((3, 3), (n - 4, 3), (3, n - 4)): # los tres ojos con su borde claro
for dy in range(-4, 5):
for dx in range(-4, 5):
pon(cx + dx, cy + dy, max(abs(dx), abs(dy)) not in (2, 4))
centros = _ALINEACION[version]
for cy in centros: # patrones de alineación
for cx in centros:
if (cx, cy) in ((6, 6), (6, n - 7), (n - 7, 6)):
continue
for dy in range(-2, 3):
for dx in range(-2, 3):
pon(cx + dx, cy + dy, max(abs(dx), abs(dy)) != 1)
for i in range(9): # sitio de la información de formato
for x, y in ((8, i), (i, 8)):
if not fijo[y][x]:
pon(x, y, False)
for i in range(8):
pon(n - 1 - i, 8, False)
pon(8, n - 1 - i, False)
pon(8, n - 8, True) # el módulo siempre oscuro
if version >= 7: # información de versión
dato = version << 12 | _bch(version, 12, 0x1F25)
for i in range(18):
a, b = n - 11 + i % 3, i // 3
pon(a, b, bool(dato >> i & 1))
pon(b, a, bool(dato >> i & 1))
return m, fijo
def _formato(m: list[list[bool]], mascara: int) -> None:
n = len(m)
dato = (mascara << 10 | _bch(mascara, 10, 0x537)) ^ 0x5412 # nivel M = 00
bit = [bool(dato >> i & 1) for i in range(15)]
for i in range(6):
m[i][8] = bit[i]
m[7][8], m[8][8], m[8][7] = bit[6], bit[7], bit[8]
for i in range(9, 15):
m[8][14 - i] = bit[i]
for i in range(8):
m[8][n - 1 - i] = bit[i]
for i in range(8, 15):
m[n - 15 + i][8] = bit[i]
def _penalizacion(m: list[list[bool]]) -> int:
n = len(m)
total = 0
lineas = [list(f) for f in m] + [[m[y][x] for y in range(n)] for x in range(n)]
for linea in lineas:
racha, color = 0, None
for v in linea: # rachas de cinco o más del mismo color
if v == color:
racha += 1
if racha == 5:
total += 3
elif racha > 5:
total += 1
else:
color, racha = v, 1
cadena = "".join("1" if v else "0" for v in linea) # el dibujo de un ojo con claro al lado
for patron in ("10111010000", "00001011101"):
inicio = cadena.find(patron)
while inicio != -1:
total += 40
inicio = cadena.find(patron, inicio + 1)
for y in range(n - 1): # bloques de 2×2 del mismo color
for x in range(n - 1):
if m[y][x] == m[y][x + 1] == m[y + 1][x] == m[y + 1][x + 1]:
total += 3
oscuros = sum(map(sum, m)) # proporción de oscuros lejos de la mitad
total += (abs(oscuros * 20 - n * n * 10) + n * n - 1) // (n * n) * 10 - 10 if oscuros * 2 != n * n else 0
return total
def matriz(texto: str | bytes, mascara: int | None = None) -> list[list[bool]]:
"""La cuadrícula del QR (sin el margen claro, que pone quien la dibuja)."""
crudo = texto.encode("utf-8") if isinstance(texto, str) else bytes(texto)
version = next((v for v in sorted(_BLOQUES) if len(crudo) <= capacidad(v)), None)
if version is None:
raise TextoDemasiadoLargo(f"El texto no cabe en un QR de los que dibuja Caudal ({capacidad(max(_BLOQUES))} bytes).")
base, fijo = _base(version)
n = len(base)
datos = _codewords(crudo, version)
i = 0
derecha = n - 1
while derecha >= 1: # zigzag de dos columnas, de abajo arriba y vuelta
if derecha == 6:
derecha = 5
for vert in range(n):
for j in (0, 1):
x = derecha - j
y = n - 1 - vert if (derecha + 1) & 2 == 0 else vert
if not fijo[y][x] and i < len(datos) * 8:
base[y][x] = bool(datos[i >> 3] >> (7 - (i & 7)) & 1)
i += 1
derecha -= 2
mejor = None
for k in (range(8) if mascara is None else (mascara,)):
m = [[base[y][x] ^ (not fijo[y][x] and _MASCARAS[k](x, y)) for x in range(n)] for y in range(n)]
_formato(m, k)
p = _penalizacion(m)
if mejor is None or p < mejor[0]:
mejor = (p, m)
return mejor[1]
def filas(texto: str | bytes) -> list[str]:
"""Lo mismo como texto, una cadena de «1» y «0» por fila: lo que se manda a la página."""
return ["".join("1" if v else "0" for v in fila) for fila in matriz(texto)]