#!/usr/bin/env python3 """ Convert between UTF-8 and UCE-8. python3 uce8_convert.py encode input.txt > output.uce8 python3 uce8_convert.py decode output.uce8 > roundtrip.txt python3 uce8_convert.py stats input.txt With no file argument each command reads standard input, so the tool composes: cat input.txt | python3 uce8_convert.py encode | python3 uce8_convert.py decode The encoder is uce.encode, one code point at a time. Everything here is the framing around it: whole-buffer conversion, boundary scanning, and the error cases a real stream can present. """ import sys from uce import decode, encode __all__ = [ "utf8_to_uce8", "uce8_to_utf8", "uce8_encode", "uce8_decode", "is_char_start", "iter_chars", "find_all", ] class UCE8Error(ValueError): """A UCE-8 byte stream that cannot be decoded.""" # --------------------------------------------------------------- encoding def uce8_encode(text): """Encode str -> UCE-8 bytes.""" return b"".join(encode(ord(c)) for c in text) def utf8_to_uce8(data): """Convert a UTF-8 byte string to a UCE-8 byte string.""" return uce8_encode(data.decode("utf-8")) # --------------------------------------------------------------- decoding def iter_chars(data): """ Yield (offset, length, code_point) for every character in a UCE-8 stream. A character is a run of lead bytes closed by one trail byte, so the scan is: advance while the high bit is set, then take the byte that clears it. """ i, n = 0, len(data) while i < n: j = i while j < n and data[j] >= 0x80: j += 1 if j >= n: raise UCE8Error( f"truncated stream: {n - i} lead byte(s) at offset {i} " "with no terminating byte" ) length = j - i + 1 if length > 3: raise UCE8Error( f"invalid sequence at offset {i}: {length} bytes, " "but UCE-8 characters are at most 3" ) try: cp = decode(data[i:j + 1]) except Exception as exc: raise UCE8Error(f"bad sequence at offset {i}: {exc}") from None yield i, length, cp i = j + 1 def uce8_decode(data): """Decode UCE-8 bytes -> str.""" return "".join(chr(cp) for _, _, cp in iter_chars(data)) def uce8_to_utf8(data): """Convert a UCE-8 byte string to a UTF-8 byte string.""" return uce8_decode(data).encode("utf-8") # ------------------------------------------------------ boundaries, search def is_char_start(data, i): """A low byte terminates a character, so position i begins one.""" return i == 0 or not (data[i - 1] & 0x80) def find_all(haystack, needle): """ Every occurrence of needle that starts on a character boundary. A plain byte search over UCE-8 can match the interior of a multi-byte character, because trail bytes are ordinary ASCII. The boundary test removes those. """ hits, i = [], haystack.find(needle) while i != -1: if is_char_start(haystack, i): hits.append(i) i = haystack.find(needle, i + 1) return hits # ------------------------------------------------------------------- cli def _read(path): if path in (None, "-"): return sys.stdin.buffer.read() with open(path, "rb") as fh: return fh.read() def _write(data): sys.stdout.buffer.write(data) def _stats(raw): text = raw.decode("utf-8") out = uce8_encode(text) u8 = len(raw) lengths = {} for c in text: lengths[len(encode(ord(c)))] = lengths.get(len(encode(ord(c))), 0) + 1 print(f"characters {len(text):>10,}", file=sys.stderr) print(f"UTF-8 {u8:>10,} bytes", file=sys.stderr) print(f"UCE-8 {len(out):>10,} bytes", file=sys.stderr) if u8: delta = (1 - len(out) / u8) * 100 word = "smaller" if delta >= 0 else "larger" print(f"difference {abs(delta):>9.1f}% {word}", file=sys.stderr) print("", file=sys.stderr) for n in sorted(lengths): share = lengths[n] / len(text) * 100 if text else 0 print(f" {n}-byte characters {lengths[n]:>9,} {share:5.1f}%", file=sys.stderr) def main(argv): if len(argv) < 2 or argv[1] in ("-h", "--help"): print(__doc__.strip(), file=sys.stderr) return 0 if len(argv) > 1 else 2 cmd = argv[1] path = argv[2] if len(argv) > 2 else None try: raw = _read(path) except OSError as exc: print(f"uce8_convert: {exc}", file=sys.stderr) return 1 try: if cmd == "encode": _write(utf8_to_uce8(raw)) elif cmd == "decode": _write(uce8_to_utf8(raw)) elif cmd == "stats": _stats(raw) else: print(f"uce8_convert: unknown command {cmd!r}", file=sys.stderr) return 2 except UnicodeDecodeError as exc: print(f"uce8_convert: input is not valid UTF-8 ({exc})", file=sys.stderr) return 1 except UCE8Error as exc: print(f"uce8_convert: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))