diff --git a/.gitignore b/.gitignore index c02743a..8413829 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,10 @@ build-common/bleak build-common/python-win32* # dev config config.json -# traffic dump +# test files *.dump +*.pf2 +*.pbm # some other junk .directory thumbs.db diff --git a/.pylintrc b/.pylintrc index a93dee8..368abe1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -17,14 +17,5 @@ disable=broad-except, import-outside-toplevel [BASIC] -good-names=i, - j, - k, - ex, - Run, - _, - e, - do_GET, - do_POST, - do_HEAD, - do_PUT +good-names=i, j, k, ex, x, y, _, e, + Run, do_GET, do_POST, do_HEAD, do_PUT diff --git a/.vscode/launch.json b/.vscode/launch.json index 85ee271..8e2be98 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,17 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Python: Text Print", + "type": "python", + "request": "launch", + "program": "printer.py", + "args": [ + "-m", "-t", "-d", "-s", "1", "-" + ], + "console": "integratedTerminal", + "justMyCode": true + }, { "name": "Python: Current File", "type": "python", diff --git a/additional/pf2.py b/additional/pf2.py new file mode 100644 index 0000000..1112865 --- /dev/null +++ b/additional/pf2.py @@ -0,0 +1,136 @@ +'Python lib to read PF2 font file: http://grub.gibibit.com/New_font_format' + +import io +from typing import Dict, Tuple + +def uint32be(b: bytes): + 'Translate 4 bytes as unsigned big-endian 32-bit int' + return ( + (b[0] << 32) + + (b[1] << 16) + + (b[2] << 8) + + b[3] + ) + +def int32be(b: bytes): + 'Translate 4 bytes as signed big-endian 32-bit int' + u = uint32be(b) + return u - ((u >> 31 & 0b1) << 32) + +def uint16be(b: bytes): + 'Translate 2 bytes as big-endian unsigned 16-bit int' + return (b[0] << 8) + b[1] + +def int16be(b: bytes): + 'Translate 2 bytes as big-endian signed 16-bit int' + u = uint16be(b) + return u - ((u >> 15 & 0b1) << 16) + +class Character(): + 'A PF2 character' + + width: int + height: int + x_offset: int + y_offset: int + device_width: int + bitmap_data: bytes + + +class PF2(): + 'The PF2 class, for serializing a PF2 font file' + + is_pf2: bool + 'Sets to false if the read file is not PF2 font file' + + missing_character_code: int + in_memory: bool + + font_name: str + family: str + weight: str + slant: str + point_size: int + max_width: int + max_height: int + ascent: int + descent: int + character_index: Dict[int, Tuple[int, int]] + data_offset: int + data_io: io.IOBase + + def __init__(self, path='font.pf2', *, read_to_mem=False, missing_character: str='?'): + self.missing_character_code = ord(missing_character) + self.in_memory = read_to_mem + file = open(path, 'rb') + self.is_pf2 = (file.read(12) == b'FILE\x00\x00\x00\x04PFF2') + if not self.is_pf2: + return + while True: + name = file.read(4) + data_length = int32be(file.read(4)) + if name == b'CHIX': + self.character_index = {} + for _ in range(data_length // (4 + 1 + 4)): + code_point = int32be(file.read(4)) + compression = file.read(1)[0] + offset = int32be(file.read(4)) + self.character_index[code_point] = ( + compression, offset + ) + continue + elif name == b'DATA': + if read_to_mem: + self.data_io = io.BytesIO(file.read()) + self.data_offset = -file.tell() + file.close() + else: + self.data_io = file + self.data_offset = 0 + break + data = file.read(data_length) + if name == b'NAME': + self.font_name = data + elif name == b'FAMI': + self.family = data + elif name == b'WEIG': + self.weight = data + elif name == b'SLAN': + self.slant = data + elif name == b'PTSZ': + self.point_size = uint16be(data) + elif name == b'MAXW': + self.max_width = uint16be(data) + elif name == b'MAXH': + self.max_height = uint16be(data) + elif name == b'ASCE': + self.ascent = uint16be(data) + elif name == b'DESC': + self.descent = uint16be(data) + + def get_char(self, char: str): + 'Get a character, returning a `Character` instance' + char_point = ord(char) + info = self.character_index.get(char_point) + if info is None: + info = self.character_index[self.missing_character_code] + _compression, offset = info + data = self.data_io + data.seek(offset + self.data_offset) + char = Character() + char.width = uint16be(data.read(2)) + char.height = uint16be(data.read(2)) + char.x_offset = int16be(data.read(2)) + char.y_offset = int16be(data.read(2)) + char.device_width = int16be(data.read(2)) + char.bitmap_data = data.read( + (char.width * char.height + 7) // 8 + ) + return char + + __getitem__ = get_char + + def close(self): + 'Close the data IO, if it\'s a real file' + if not self.in_memory: + self.data_io.close() diff --git a/build-android/0-build-android.sh b/build-android/0-build-android.sh index ba2c354..4306364 100755 --- a/build-android/0-build-android.sh +++ b/build-android/0-build-android.sh @@ -1,6 +1,6 @@ #!/bin/sh p4a apk --private .. --dist_name="cat-printer" --package="io.github.naitlee.catprinter" --name="Cat Printer" \ - --icon=icon.png --version="0.1.0" --bootstrap=webview --window --requirements=android,pyjnius,bleak \ + --icon=icon.png --version="0.1.1" --bootstrap=webview --window --requirements=android,pyjnius,bleak \ --blacklist-requirements=sqlite3,openssl --port=8095 --arch=arm64-v8a --blacklist="blacklist.txt" \ --presplash=blank.png --presplash-color=black --add-source="advancedwebview" --orientation=user \ --permission=BLUETOOTH --permission=BLUETOOTH_SCAN --permission=BLUETOOTH_CONNECT \ diff --git a/build-common/0-bundle-all.sh b/build-common/0-bundle-all.sh index d57c827..aeba9f6 100755 --- a/build-common/0-bundle-all.sh +++ b/build-common/0-bundle-all.sh @@ -1,4 +1,5 @@ #!/bin/sh +for i in $(find | grep -E '.*\.pyc'); do rm $i; done python3 bundle.py $1 python3 bundle.py -w $1 python3 bundle.py -b $1 diff --git a/build-common/bundle.py b/build-common/bundle.py index 008e10a..29a836c 100644 --- a/build-common/bundle.py +++ b/build-common/bundle.py @@ -38,7 +38,9 @@ additional_ignore = ( '.pylintrc', '.gitignore', 'dev-diary.txt', - 'TODO' + 'TODO', + # cache + '*.pyc', # other '.directory', 'thumbs.db', diff --git a/printer.py b/printer.py index 6d04503..57025a3 100644 --- a/printer.py +++ b/printer.py @@ -119,6 +119,7 @@ class PBMData(): height: int data: bytes args: dict + 'Note: going to put this in `PrinterDriver` in the future' def __init__(self, width: int, height: int, data: bytes, args: dict = None): self.width = width @@ -134,6 +135,57 @@ class PBMData(): for arg in args: self.args[arg] = args[arg] +class TextCanvas(): + 'Canvas for text printing, requires PF2 lib' + width: int + height: int + canvas: bytearray = None + pf2 = None + def __init__(self, width): + if self.pf2 is None: + from additional.pf2 import PF2 + self.pf2 = PF2() + self.width = width + self.height = self.pf2.max_height + self.pf2.descent + self.flush_canvas() + def flush_canvas(self): + 'Flush the canvas, returning the canvas data' + if self.canvas is None: + pbm_data = None + else: + pbm_data = bytearray(self.canvas) + self.canvas = bytearray(self.width * self.height // 8) + return pbm_data + def puttext(self, text): + 'Put the specified text to canvas' + current_width = 0 + canvas_length = len(self.canvas) + pf2 = self.pf2 + for i in text: + char = pf2[i] + if ( + current_width + char.width + char.x_offset > self.width or + i == '\n' + ): + yield self.flush_canvas() + current_width = 0 + if i in '\n': # glyphs that should not be printed out + continue + for x in range(char.width): + for y in range(char.height): + target_x = x + char.x_offset + target_y = pf2.ascent + (y - char.height) - char.y_offset + canvas_byte = (self.width * target_y + current_width + target_x) // 8 + canvas_bit = 7 - (self.width * target_y + current_width + target_x) % 8 + if canvas_byte < 0 or canvas_byte >= canvas_length: + continue + char_byte = (char.width * y + x) // 8 + char_bit = 7 - (char.width * y + x) % 8 + self.canvas[canvas_byte] |= ( + char.bitmap_data[char_byte] & (0b1 << char_bit) + ) >> char_bit << canvas_bit + current_width += char.device_width + return class PrinterDriver(): 'Manipulator of the printer' @@ -152,20 +204,26 @@ class PrinterDriver(): feed_after = 128 'Extra paper to feed at the end of printing, by pixel' - dry_run = False + dry_run = None 'Is dry run (emulate print process but print nothing)' - standard_width = 384 + dump = None + 'Dump the traffic (and PBM image when text printing)?' + + paper_width = 384 'It\'s a constant for the printer' - pbm_data_per_line = int(standard_width / 8) # 48 - 'Constant, determined by standard width & PBM data format' + pbm_data_per_line = int(paper_width / 8) # 48 + 'Determined by paper width & PBM data format' characteristic = '0000ae01-0000-1000-8000-00805f9b34fb' 'The BLE characteristic, a constant of the printer' mtu = 200 + text_canvas: TextCanvas = None + 'A `TextCanvas` instance' + def __init__(self): pass @@ -192,7 +250,7 @@ class PrinterDriver(): if line[0:1] != b'#': break width, height = [int(x) for x in line.split(b' ')[0:2]] - if width != self.standard_width: + if width != self.paper_width: raise Exception('PBM image width is not 384px') total_height += height expected_data_size = self.pbm_data_per_line * height @@ -205,7 +263,7 @@ class PrinterDriver(): result += b'\x00' * expected_data_size else: result += raw_data - return PBMData(self.standard_width, total_height, result) + return PBMData(self.paper_width, total_height, result) def _pbm_data_to_raw(self, data: PBMData): buffer = bytearray() @@ -254,6 +312,9 @@ class PrinterDriver(): async def send_buffer(self, buffer: bytearray, address: str = None): 'Send manipulation data (buffer) to the printer via bluetooth' + if self.dump: + with open('traffic.dump', 'wb') as file: + file.write(buffer) address = address or self.address client = BleakClient(address, timeout=5.0) await client.connect() @@ -316,7 +377,7 @@ class PrinterDriver(): if device.name == info: self.name, self.address = device.name, device.address break - elif info[2::3] == ':::::': + elif info[2::3] == ':::::' or len(info.replace('-', '')) == 32: for device in devices: if device.address.lower() == info.lower(): self.name, self.address = device.name, device.address @@ -326,6 +387,27 @@ class PrinterDriver(): self.name, self.address = device.name, device.address return True + async def print_text(self, file_io: io.IOBase): + 'Print some text from `file_io`' + if self.text_canvas is None: + self.text_canvas = TextCanvas(self.paper_width) + canvas = self.text_canvas + header = b'P4\n%i %i\n' + dump = bytearray() + current_height = 0 + while True: + text = file_io.readline() + if not text: + break + for data in canvas.puttext(text): + if self.dump: + dump += data + current_height += canvas.height + with open('dump.pbm', 'wb') as file: + file.write(header % (canvas.width, current_height)) + file.write(dump) + await self.print_data(bytearray(header % (canvas.width, canvas.height)) + data) + async def _main(): 'Main routine for direct command line execution' @@ -347,6 +429,10 @@ async def _main(): help=i18n['communication-frequency-0.8-or-1-recommended']) parser.add_argument('-d', '--dry', required=False, action='store_true', help=i18n['dry-run-test-print-process-only']) + parser.add_argument('-m', '--dump', required=False, action='store_true', + help=i18n['dump-the-traffic-to-printer-and-pbm-image-when-text-printing']) + parser.add_argument('-t', '--text', required=False, action='store_true', + help=i18n['text-printing-mode-input-text-from-stdin']) cmdargs = parser.parse_args() addr = cmdargs.address printer = PrinterDriver() @@ -367,9 +453,19 @@ async def _main(): 'name': device.name, 'address': device.address, 'frequency': cmdargs.freq, - 'dry_run': cmdargs.dry + 'dry_run': cmdargs.dry, + 'dump': cmdargs.dump }) - await printer.print_file(cmdargs.file) + if cmdargs.text: + if cmdargs.file == '-': + file = sys.stdin + else: + file = open(cmdargs.file, 'r', encoding='utf-8') + await printer.print_text(file) + if cmdargs.file != '-': + file.close() + else: + await printer.print_file(cmdargs.file) print(i18n['finished']) diff --git a/www/lang/en-US.json b/www/lang/en-US.json index 48749a6..166f517 100644 --- a/www/lang/en-US.json +++ b/www/lang/en-US.json @@ -59,6 +59,9 @@ "path-to-pbm-file-dash-for-stdin": "Path to PBM file. '-' for stdin.", "scan-for-specified-seconds": "Scan for specified seconds", "specify-printer-mac-address": "Specify printer MAC address", - "communication-frequency-0.8-or-1-recommended": "Communication frequency. 0.8 or 1 recommended." + "communication-frequency-0.8-or-1-recommended": "Communication frequency. 0.8 or 1 recommended.", + + "dump-the-traffic-to-printer-and-pbm-image-when-text-printing": "Dump the traffic to printer, and PBM image when text printing.", + "text-printing-mode-input-text-from-stdin": "Text printing mode, input from stdin." } } \ No newline at end of file diff --git a/www/lang/zh-CN.json b/www/lang/zh-CN.json index 55229d5..a079ee1 100644 --- a/www/lang/zh-CN.json +++ b/www/lang/zh-CN.json @@ -58,6 +58,9 @@ "path-to-pbm-file-dash-for-stdin": "PBM 文件的位置。“-” 作为标准输入。", "scan-for-specified-seconds": "扫描指定的时长。", "specify-printer-mac-address": "指定打印机的 MAC 地址", - "communication-frequency-0.8-or-1-recommended": "通讯频率。推荐 0.8 或 1。" + "communication-frequency-0.8-or-1-recommended": "通讯频率。推荐 0.8 或 1。", + + "dump-the-traffic-to-printer-and-pbm-image-when-text-printing": "转储到打印机的数据,和文字打印模式的 PBM 图像。", + "text-printing-mode-input-text-from-stdin": "文字打印模式,将从标准输入读取文字。" } } \ No newline at end of file