Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123"""Sigil: bytes of a known length written as a sigil, one word of letters anddigits that a double-click selects whole, with no two characters alike, anda check that confirms a reading or picks the right one of several.
encode(data) -> str # the sigil decode(sigil, length) -> bytes # or raises DecodeError decode_readings(readings, length) -> list # every reading that decodes
The specification is README.md ("The encoding"). The data, multiplied by aprime K larger than N, is written in base N = 1,353, in the fewestcharacters that leave room for such a K: one more than the data needs, atalmost every length (for 32 bytes, 26 characters and K = 22,381). Adecoder checks that the value is a multiple of K, which catches everychanged character and every swap of neighbours, and lets decode_readingsrecover any one character from all its possible readings. Decoding skipswhite space and default-ignorable characters (ignored.txt) and reads aletter's uppercase as the letter.
For maintainers: Sigil writes the data in order, without jumbling it.README.md ("Telling sigils apart", "Why Sigil writes the data in order")says what jumbling would and would not accomplish, case by case, and whySigil leaves it to the callers whose data needs it; read it before addingany mixing of the data to the encoding."""from pathlib import Path
ROOT = Path(__file__).resolve().parent.parentALPHABET = (ROOT / "alphabet.txt").read_text(encoding="utf-8")UPPERCASE = (ROOT / "uppercase.txt").read_text(encoding="utf-8")N = len(ALPHABET)assert N == 1353 and len(UPPERCASE) == NDIGIT = {**{c: d for d, c in enumerate(UPPERCASE)}, **{c: d for d, c in enumerate(ALPHABET)}}IGNORED = [tuple(int(x, 16) for x in line.split("..")) for line in (ROOT / "ignored.txt").read_text().split()]
class DecodeError(ValueError): """A sigil that encodes no data of the length asked for. `kind` is "length" (the wrong number of characters), "character" (one outside the alphabet), "range" (a value too large for the length), or "check" (the check fails: a character was misread or changed).""" def __init__(self, kind, message): super().__init__(message) self.kind = kind
def is_prime(k): return k > 1 and all(k % p for p in range(2, int(k ** 0.5) + 1))
# The smallest prime larger than N: K must exceed N (README.md, "Why the# check catches what it does").LEAST_K = next(k for k in range(N + 1, 2 * N) if is_prime(k))
def layout(length): """(n, K) for data of `length` bytes (at least 1): the sigil's characters, the fewest whose room R = floor(N^n / 2^(8 * length)) holds a prime larger than N, and K, the largest prime at most R.""" assert length >= 1, "Sigil encodes at least one byte" n = 1 while N ** n >> 8 * length < LEAST_K: n += 1 k = N ** n >> 8 * length while not is_prime(k): k -= 1 return n, k
def encode(data: bytes) -> str: """`data`'s sigil, layout(len(data))[0] characters of the alphabet. Its protection against look-alikes holds for data that looks random (a secure hash's output, a key, a random identifier): hash or jumble structured data before encoding it (README.md, "Data with structure: hash it, or jumble it").""" n, k = layout(len(data)) w = int.from_bytes(data, "big") * k out = [] for _ in range(n): w, d = divmod(w, N) out.append(ALPHABET[d]) return "".join(reversed(out))
def ignored(ch): return any(a <= ord(ch) <= b for a, b in IGNORED)
def decode(sigil: str, length: int) -> bytes: """The `length` bytes `sigil` encodes; its white space and default-ignorable characters are skipped, and a letter's uppercase reads as the letter.""" n, k = layout(length) chars = [(i, ch) for i, ch in enumerate(sigil) if not ignored(ch)] if len(chars) != n: raise DecodeError("length", f"{len(chars)} characters; {length} bytes take {n}") w = 0 for i, ch in chars: if ch not in DIGIT: raise DecodeError("character", f"character {i + 1} (U+{ord(ch):04X}) is not in the alphabet") w = w * N + DIGIT[ch] if w >= k << 8 * length: raise DecodeError("range", f"its value is too large for {length} bytes: a character was misread or changed") if w % k: raise DecodeError("check", "the check fails: a character was misread or changed") return (w // k).to_bytes(length, "big")
def decode_readings(readings, length): """Every data of `length` bytes that some reading decodes to, for a reader unsure of some characters (an OCR tool, say): `readings` gives each character's possible readings, one string a character, the likeliest first. One result is the answer; more mean the readings leave the sigil ambiguous. The work is the product of the readings' counts.""" import itertools found = [] for choice in itertools.product(*readings): try: data = decode("".join(choice), length) except DecodeError: continue if data not in found: found.append(data) return found