Share card: render the QR in braille, one dot per module
A braille cell packs a 2x4 block of modules, so the code draws at half the width and a quarter of the height of the half-block form (27x14 vs 53x27 for a contact URL) — small enough for the 40-column PicoCalc panel. Simulated-camera decode tests pass with dot fills down to 50%, since a phone's blur merges the dots into blobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25527356a4
commit
f6306170ed
4 files changed
+99
-3
No files matched your search
@@ -815,13 +815,13 @@ async def show_contact_card(
|
||||
node_type: The MeshCore advert type byte (1 companion, 2 repeater, 3 room,
|
||||
4 sensor).
|
||||
"""
|
||||
from .qr import qr_text
|
||||
from .qr import qr_braille
|
||||
|
||||
url = contact_share_url(name, public_key, node_type)
|
||||
body = Group(
|
||||
Text("Scan to add this node as a contact:", style="muted"),
|
||||
Text(""),
|
||||
qr_text(url),
|
||||
qr_braille(url),
|
||||
Text(""),
|
||||
Text(url, style="accent"),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,12 @@ Each character cell stacks two vertical modules — ``▀`` (upper only), ``▄`
|
||||
one and comfortably fits an interactive window. Modules are always drawn black on a white
|
||||
background (with the mandatory quiet-zone border), so a phone camera reads the code no
|
||||
matter what colour theme the terminal itself is using.
|
||||
|
||||
:func:`qr_braille` is the dense variant: one braille dot per module packs a 2×4 block of
|
||||
modules into every cell — half the columns and a quarter of the rows of the half-block
|
||||
form. The trade is ink coverage: a braille dot is a dot, not a filled cell, so dark
|
||||
regions carry white in-between and scanning leans on the camera's tolerance for
|
||||
dot-style codes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +33,12 @@ _GLYPH = {
|
||||
#: contrast holds regardless of the surrounding terminal palette.
|
||||
_STYLE = "black on white"
|
||||
|
||||
#: Braille dot bit for each (column, row) of a cell's 2×4 module block, rows top-down —
|
||||
#: the Unicode block numbers dots 1,2,3,7 down the left column and 4,5,6,8 down the
|
||||
#: right (``braillechart`` keeps the same tables flipped bottom-up for bar math; QR
|
||||
#: rows arrive top-down, so these stay in raster order).
|
||||
_BRAILLE_BITS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
|
||||
|
||||
|
||||
def qr_text(data: str, *, error: str = "m") -> Text:
|
||||
"""Render ``data`` as a QR code built from half-block characters.
|
||||
@@ -54,3 +66,45 @@ def qr_text(data: str, *, error: str = "m") -> Text:
|
||||
text.append(line, style=_STYLE)
|
||||
text.append("\n")
|
||||
return text
|
||||
|
||||
|
||||
def qr_braille(data: str, *, error: str = "m") -> Text:
|
||||
"""Render ``data`` as a QR code built from braille characters, one dot per module.
|
||||
|
||||
Each cell carries a 2-wide × 4-tall block of modules, so the code draws at half the
|
||||
width and a quarter of the height of :func:`qr_text` — small enough to leave room
|
||||
around it even on the 40-column PicoCalc panel. Same black-on-white contract; the
|
||||
empty cell is ``U+2800`` (the blank braille pattern) rather than a space so every
|
||||
glyph comes from the one Unicode block and renders at one consistent width.
|
||||
|
||||
Args:
|
||||
data: The string to encode.
|
||||
error: QR error-correction level — ``l``/``m``/``q``/``h`` (default ``m``).
|
||||
|
||||
Returns:
|
||||
A Rich :class:`~rich.text.Text` (no-wrap) whose lines draw the code.
|
||||
"""
|
||||
import segno
|
||||
|
||||
qr = segno.make(data, error=error)
|
||||
rows = [[bool(v) for v in row] for row in qr.matrix_iter(border=_BORDER)]
|
||||
# Pad to whole cells with light modules: out to a multiple of 4 rows and 2 columns.
|
||||
while len(rows) % 4:
|
||||
rows.append([False] * len(rows[0]))
|
||||
if len(rows[0]) % 2:
|
||||
for row in rows:
|
||||
row.append(False)
|
||||
|
||||
text = Text(no_wrap=True)
|
||||
for band in zip(*(rows[i::4] for i in range(4)), strict=True):
|
||||
line = ""
|
||||
for x in range(0, len(band[0]), 2):
|
||||
bits = 0
|
||||
for col in (0, 1):
|
||||
for dot_row in range(4):
|
||||
if band[dot_row][x + col]:
|
||||
bits |= _BRAILLE_BITS[col][dot_row]
|
||||
line += chr(0x2800 + bits)
|
||||
text.append(line, style=_STYLE)
|
||||
text.append("\n")
|
||||
return text
|
||||
@@ -91,7 +91,8 @@ async def test_show_contact_card_pops_the_qr_over_the_link() -> None:
|
||||
console.print(renderable)
|
||||
out = console.export_text()
|
||||
assert f"meshcore://contact/add?name=Hub&public_key={'ab' * 32}&type=2" in out
|
||||
assert "█" in out # the QR actually drew its modules
|
||||
# The QR actually drew its modules — braille dots, one per module.
|
||||
assert any(0x2801 <= ord(ch) <= 0x28FF for ch in out)
|
||||
|
||||
|
||||
# -- coordinate parsing ----------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The terminal QR renderers: half-block and braille forms of the same matrix."""
|
||||
|
||||
import segno
|
||||
|
||||
from meshterm.ui.qr import _BORDER, _BRAILLE_BITS, qr_braille, qr_text
|
||||
|
||||
URL = "meshcore://contact/add?name=Test&public_key=" + "ab" * 32 + "&type=1"
|
||||
|
||||
|
||||
def _matrix(data: str) -> list[list[bool]]:
|
||||
qr = segno.make(data, error="m")
|
||||
return [[bool(v) for v in row] for row in qr.matrix_iter(border=_BORDER)]
|
||||
|
||||
|
||||
def test_qr_text_matches_matrix() -> None:
|
||||
rows = _matrix(URL)
|
||||
lines = qr_text(URL).plain.rstrip("\n").split("\n")
|
||||
glyph_bits = {"█": (True, True), "▀": (True, False), "▄": (False, True), " ": (False, False)}
|
||||
for y, row in enumerate(rows):
|
||||
for x, dark in enumerate(row):
|
||||
top, bottom = glyph_bits[lines[y // 2][x]]
|
||||
assert (top if y % 2 == 0 else bottom) == dark
|
||||
|
||||
|
||||
def test_qr_braille_matches_matrix() -> None:
|
||||
"""Every module maps to exactly its braille dot — one dot per module, raster order."""
|
||||
rows = _matrix(URL)
|
||||
lines = qr_braille(URL).plain.rstrip("\n").split("\n")
|
||||
for y, row in enumerate(rows):
|
||||
for x, dark in enumerate(row):
|
||||
cell = ord(lines[y // 4][x // 2]) - 0x2800
|
||||
assert bool(cell & _BRAILLE_BITS[x % 2][y % 4]) == dark
|
||||
|
||||
|
||||
def test_qr_braille_geometry() -> None:
|
||||
"""Whole-cell padding: every line is braille-block glyphs at half width, quarter height."""
|
||||
rows = _matrix(URL)
|
||||
lines = qr_braille(URL).plain.rstrip("\n").split("\n")
|
||||
assert len(lines) == -(-len(rows) // 4)
|
||||
assert all(len(line) == -(-len(rows[0]) // 2) for line in lines)
|
||||
assert all(0x2800 <= ord(ch) <= 0x28FF for line in lines for ch in line)
|
||||
Reference in new issue
Block a user