Source code for t61codec

"""
Python codec for ITU-T Rec. T.61 (11/1988), the 8-bit basic Teletex code.

The codec follows the 1988 edition of T.61 to the letter:

* **Graphic characters** are the primary set (Table 1/T.61) and the
  supplementary set (Table 2/T.61). 0xE0 decodes to U+03A9 and 0xE2 to
  U+0110; on encode, U+2126 and U+00D0 are accepted as aliases. The
  receive-only bytes 0x23 and 0x24 decode to ``#`` and ``¤`` but are
  never produced: the encoder uses 0xA6 and 0xA8.
* **Diacritics** (0xC1-0xCF) are prefixes. A diacritic plus a base
  letter decodes to the precomposed character, but only for the 155
  combinations of Figure B-1/T.61. A diacritic plus SPACE decodes to the
  spacing accent (for example ``C2 20`` gives U+00B4). 0xC9, the umlaut
  of the 1980 edition, is a decode-only alias of 0xC8.
* **Non-spacing underline** (0xCC) may precede any graphic character,
  including an accented letter or SPACE, with PLD/PLU allowed in
  between. It decodes to the base character followed by U+0332
  COMBINING LOW LINE; intervening PLD/PLU are emitted before the base.
* **Control functions**: only BS, LF, FF, CR, SUB, PLD, PLU and CSI are
  accepted, by identity. SUB is accepted on decode only. CSI decodes to
  U+009B; the parameter and final bytes of a control sequence are
  ordinary primary-set bytes and are decoded as such (T.61 defines no
  further obligation for a character codec). Code extension (ESC, SS2,
  SS3, LS0, LS1, RLF) is not supported.
* **Encoding** normalises each character cluster to NFC, then emits the
  single byte or the diacritic sequence (underline first). The output is
  canonical: one byte string per NFC text. The incremental encoder and
  the stream writer encode each chunk on its own, so a character and its
  combining marks must not be split across chunks.

Everything else is an error, reported through the usual error handlers.
Every decode error spans one byte and decoding resumes right after it.
An encode error spans the cluster (base character plus combining marks)
in the original, non-normalised input (``replace`` gives one ``?`` per
code point). ``backslashreplace`` cannot work on encode because T.61 has
no backslash; it raises the original error with an explanatory reason.

**Differences from glibc** (``iconv -f T.61``): glibc passes all C0/C1
controls and 0x7F through, rejects 0x23/0x24/0xC9, decodes 0xE0/0xE2 to
U+2126/U+00D0, rejects ``C2 67`` (ǵ), ``C1/C3/C4 20`` (spacing grave,
circumflex and tilde) and the non-spacing underline 0xCC, and accepts
``CB 67`` (ģ), which Figure B-1 does not list.

**Real-world note:** OpenSSL treats T61String as Latin-1, so
TeletexString data found in X.509 certificates is often Latin-1 rather
than T.61, and this codec may reject or alter it.

See https://www.itu.int/rec/T-REC-T.61-198811-S/en
"""

import codecs
from importlib.metadata import PackageNotFoundError, version
from typing import TYPE_CHECKING

from t61codec import _codec

if TYPE_CHECKING:
    from typing_extensions import Buffer

try:
    __version__ = version("t61codec")
except PackageNotFoundError:  # not installed, e.g. a vendored copy
    __version__ = "0+unknown"


[docs] class Codec(codecs.Codec): """ Stateless T.61 encoder and decoder. """
[docs] def encode(self, input: str, errors: str = "strict") -> tuple[bytes, int]: """ Encode *input* completely. :param input: Text to encode. :param errors: Name of a registered error handler. :returns: The encoded bytes and the number of characters consumed. """ return _codec.encode(input, errors)
[docs] def decode(self, input: "Buffer", errors: str = "strict") -> tuple[str, int]: """ Decode *input* completely; a trailing prefix byte is an error. :param input: Bytes to decode. :param errors: Name of a registered error handler. :returns: The decoded text and the number of bytes consumed. """ return _codec.decode(input, errors, final=True)
[docs] class IncrementalEncoder(codecs.IncrementalEncoder): """ Incremental T.61 encoder. Each chunk is encoded on its own and nothing is held back, because :class:`io.TextIOWrapper` never calls ``encode(..., final=True)``: a held-back character would be lost when a file is closed. A chunk that starts with a combining mark therefore fails like any other combining mark without a base. Keep each character and its combining marks in the same chunk. """
[docs] def encode(self, input: str, final: bool = False) -> bytes: """ Encode one chunk of text. :param input: Next chunk of text. :param final: Ignored; the encoder keeps no state. :returns: The encoded bytes. """ return _codec.encode(input, self.errors)[0]
[docs] class IncrementalDecoder(codecs.BufferedIncrementalDecoder): """ Incremental T.61 decoder. A trailing diacritic or underline prefix is buffered until the next call. With ``final=True`` it is an error. """ def _buffer_decode( self, input: "Buffer", errors: str, final: bool ) -> tuple[str, int]: return _codec.decode(input, errors, final)
[docs] class StreamWriter(Codec, codecs.StreamWriter): """ T.61 stream writer. Each :meth:`write` is encoded on its own, as with :class:`IncrementalEncoder`: a character and its combining marks must be written in the same call. """
[docs] class StreamReader(Codec, codecs.StreamReader): """ T.61 stream reader. Prefix bytes at the end of one read are kept until the next read. :meth:`read` without a size reports a truncated prefix at the end of the stream as an error; :meth:`readline` cannot detect it and leaves it in the buffer. :class:`io.TextIOWrapper` (``open(..., encoding="t.61")``) always reports it. """ bytebuffer: bytes # typeshed leaves the attribute's type undetermined
[docs] def decode( self, input: "Buffer", errors: str = "strict", final: bool = False ) -> tuple[str, int]: """ Decode *input*, leaving an incomplete trailing sequence unread. :param input: Bytes to decode. :param errors: Name of a registered error handler. :param final: If true, a trailing prefix byte is an error. :returns: The decoded text and the number of bytes consumed. """ return _codec.decode(input, errors, final)
[docs] def read(self, size: int = -1, chars: int = -1, firstline: bool = False) -> str: """ Read and decode, as :meth:`codecs.StreamReader.read`. :param size: Approximate number of bytes to read; -1 for all. :param chars: Number of characters to return; -1 for all. :param firstline: Return only the first line if decoding fails. :returns: The decoded text. """ text = super().read(size, chars, firstline) if size < 0 and chars < 0 and self.bytebuffer: tail, _ = self.decode(self.bytebuffer, self.errors, final=True) self.bytebuffer = b"" text += tail return text
[docs] def getregentry() -> codecs.CodecInfo: """ Create a :py:class:`codecs.CodecInfo` instance for the registry. """ return codecs.CodecInfo( name="t.61", encode=Codec().encode, decode=Codec().decode, incrementalencoder=IncrementalEncoder, incrementaldecoder=IncrementalDecoder, streamreader=StreamReader, streamwriter=StreamWriter, )
[docs] def search_function(encoding: str) -> codecs.CodecInfo | None: """ Look up the codec; usable with :py:func:`codecs.register`. Returns a CodecInfo for t61/t.61 encodings, or None for unknown encodings to allow other search functions to handle them. As a convenience, there is also :func:`~.register` in this module. """ if encoding.lower() in ("t61", "t.61"): return getregentry() return None
[docs] def register() -> None: r""" Register the T.61 search function with Python's codec registry. Example: >>> import t61codec >>> t61codec.register() >>> b'Hello T.61: \xe0 Caf\xc2e'.decode('t.61') 'Hello T.61: Ω Café' """ codecs.register(search_function)