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.
237 lines
9.1 KiB
Python
237 lines
9.1 KiB
Python
#!/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)]
|