More work to improve documentation generated by sphinx. Also fixes
[pyutils.git] / src / pyutils / compress / letter_compress.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """
6 This is a simple, honestly, toy compression scheme that uses a custom
7 alphabet of 32 characters which can each be represented in six bits
8 instead of eight.  It therefore reduces the size of data composed of
9 only those letters by 25% without loss.
10 """
11
12 import bitstring
13
14 from pyutils.collectionz.bidict import BiDict
15
16 special_characters = BiDict(
17     {
18         ' ': 27,
19         '.': 28,
20         ',': 29,
21         "-": 30,
22         '"': 31,
23     }
24 )
25
26
27 def compress(uncompressed: str) -> bytes:
28     """Compress a word sequence into a stream of bytes.  The compressed
29     form will be 5/8th the size of the original.  Words can be lower
30     case letters or special_characters (above).
31
32     Args:
33         uncompressed: the uncompressed string to be compressed
34
35     Returns:
36         the compressed bytes
37
38     >>> import binascii
39     >>> binascii.hexlify(compress('this is a test'))
40     b'a2133da67b0ee859d0'
41
42     >>> binascii.hexlify(compress('scot'))
43     b'98df40'
44
45     >>> binascii.hexlify(compress('scott'))  # Note the last byte
46     b'98df4a00'
47
48     """
49     compressed = bitstring.BitArray()
50     for letter in uncompressed:
51         if 'a' <= letter <= 'z':
52             bits = ord(letter) - ord('a') + 1  # 1..26
53         else:
54             if letter not in special_characters:
55                 raise Exception(
56                     f'"{uncompressed}" contains uncompressable char="{letter}"'
57                 )
58             bits = special_characters[letter]
59         compressed.append(f"uint:5={bits}")
60     while len(compressed) % 8 != 0:
61         compressed.append("uint:1=0")
62     return compressed.bytes
63
64
65 def decompress(compressed: bytes) -> str:
66     """
67     Decompress a previously compressed stream of bytes back into
68     its original form.
69
70     Args:
71         compressed: the compressed data to decompress
72
73     Returns:
74         The decompressed string
75
76     >>> import binascii
77     >>> decompress(binascii.unhexlify(b'a2133da67b0ee859d0'))
78     'this is a test'
79
80     >>> decompress(binascii.unhexlify(b'98df4a00'))
81     'scott'
82
83     """
84     decompressed = ''
85     kompressed = bitstring.BitArray(compressed)
86
87     # There are compressed messages that legitimately end with the
88     # byte 0x00.  The message "scott" is an example; compressed it is
89     # 0x98df4a00.  It's 5 characters long which means there are 5 x 5
90     # bits of compressed info (25 bits, just over 3 bytes).  The last
91     # (25th) bit in the steam happens to be a zero.  The compress code
92     # padded out the compressed message by adding seven more zeros to
93     # complete the partial 4th byte.  In the 4th byte, however, one
94     # bit is information and seven are padding.
95     #
96     # It's likely that this API's client code may treat a zero byte as
97     # a termination character and not regard it as a legitimate part
98     # of the message.  This is a bug in that client code, to be clear.
99     #
100     # However, it's a bug we can work around:
101     #
102     # Here, I'm appending an extra 0x00 byte to the compressed message
103     # passed in.  If the client code dropped the last 0x00 byte (and,
104     # with it, some of the legitimate message bits) by treating it as
105     # a termination mark, this 0x00 will replace it (and the missing
106     # message bits).  If the client code didn't drop the last 0x00 (or
107     # if the compressed message didn't end in 0x00), adding an extra
108     # 0x00 is a no op because the codepoint 0b00000 is a "stop" message
109     # so we'll ignore the extras.
110     kompressed.append("uint:8=0")
111
112     for chunk in kompressed.cut(5):
113         chunk = chunk.uint
114         if chunk == 0:
115             break
116         elif 1 <= chunk <= 26:
117             letter = chr(chunk - 1 + ord('a'))
118         else:
119             letter = special_characters.inverse[chunk][0]
120         decompressed += letter
121     return decompressed
122
123
124 if __name__ == '__main__':
125     import doctest
126
127     doctest.testmod()