diff --git a/.gitignore b/.gitignore index 8413829..a3b99d4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,6 @@ __pycache__ # Compatibility version of script, for old-old webView, # generated by typescript tsc www/main.comp.js -# https://github.com/roddeh/i18njs -www/i18n.js -www/i18n.d.ts # https://www.npmjs.com/package/vconsole www/vconsole.js # https://github.com/delight-im/Android-AdvancedWebView @@ -15,6 +12,7 @@ build-android/advancedwebview # releases build-android/dist *.apk +*.apk.* cat-printer*.zip # bleak, the bare pip package as a folder build-common/bleak @@ -22,6 +20,8 @@ build-common/bleak build-common/python-win32* # dev config config.json +# dev backup +*.bak # test files *.dump *.pf2 diff --git a/.pylintrc b/.pylintrc index 368abe1..fb1407a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -7,15 +7,15 @@ jobs=4 [BASIC] -class-const-naming-style=snake_case -const-naming-style=snake_case +class-const-naming-style=PascalCase +const-naming-style=PascalCase [MESSAGES CONTROL] disable=broad-except, global-statement, - fixme, + fixme, too-few-public-methods, import-outside-toplevel [BASIC] -good-names=i, j, k, ex, x, y, _, e, +good-names=i, j, k, ex, x, y, _, e, b, u, s, Run, do_GET, do_POST, do_HEAD, do_PUT diff --git a/.vscode/launch.json b/.vscode/launch.json index 8e2be98..f7e7b9a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,13 +4,24 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Python: Bitmap Print", + "type": "python", + "request": "launch", + "program": "printer.py", + "args": [ + "-m", "-s", "1", "dump.pbm" + ], + "console": "integratedTerminal", + "justMyCode": true + }, { "name": "Python: Text Print", "type": "python", "request": "launch", "program": "printer.py", "args": [ - "-m", "-t", "-d", "-s", "1", "-" + "-mt", "-f", "GB02", "COPYING" ], "console": "integratedTerminal", "justMyCode": true diff --git a/0-transpile.sh b/0-transpile.sh index 1229ef1..deb6bd8 100755 --- a/0-transpile.sh +++ b/0-transpile.sh @@ -1,4 +1,4 @@ #!/bin/sh cd www -npx tsc --allowJs --outFile main.comp.js polyfill.js i18n.js image.js main.js +npx tsc --allowJs --outFile main.comp.js polyfill.js i18n-ext.js i18n.js image.js main.js cd .. diff --git a/COPYING b/COPYING index 58e1459..61f8e93 100644 --- a/COPYING +++ b/COPYING @@ -3,4 +3,4 @@ This program is free software: you can redistribute it and/or modify it under th This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -You should have received a copy of the GNU General Public License along with this program. If not, see . +You should have received a copy of the GNU General Public License along with this program. If not, see . diff --git a/README.md b/README.md index 7312d0a..6629e94 100644 --- a/README.md +++ b/README.md @@ -10,35 +10,36 @@ English | [Deutsch](./readme.i18n/README.de_DE.md) | [简体中文](./readme.i18 Currently: -| | | -|-------------|-------------------| -| Supported | GB01, GB02, GT01 | -| Maybe | GB03 | -| Planned | N/A | +| | | +|----|----| +| Supported | GB01, GB02, GT01, GB03 | + + ## Features *Currently it's in development. More will be here soon!* -| Available | Partial | Planned | -|-----------------|-----------|---------------| -| Web Interface | CUPS/IPP* | Visual Editor | -| Print a Picture | | Help/Manual | -| Command-line | | Text Printing | - - -\* In development code. Will be released in a short period. - -*Along with...* - - Simple! - - Operate via a Web UI just in browser, + - Operate via Web UI just in browser, - or get the Android release! + - Even no problem with command line hackers! - Friendly! - Language support! You can participate in translation! - Good user interface, adaptive to PC/mobile and light/dark theme! +- Feature-rich! + - Web UI, for most people! + - Take full control of printer config + - Print picture, or just test if it works + - Command line, for geeks & hackers! + - Control printer with a few parameters + - Simplified text printing + - Make use of every part of the program + - Some other goodies! + - Server program is also CUPS/IPP capable + - Cross platform! - Newer Windows 10 and above - GNU/Linux @@ -78,33 +79,36 @@ On Arch Linux based distros you may first install `bluez`, as it's often missing sudo pacman -S bluez bluez-utils ``` +*Packaging is also on the way!* + ### MacOS -For MacOS please install [Python 3](https://www.python.org/). +For MacOS please install [Python 3](https://www.python.org/), +then install `pyobjc` and `bleak` via `pip` in terminal: +```bash +pip3 install pyobjc bleak +``` -Fetch a "pure" release and do the same in a shell: +After that, fetch a "bare" release and do the same in a shell: ```bash python3 server.py ``` Currently in Mac the browser will not pop up automatically. Please run manually and go to `http://127.0.0.1:8095`, or just click [here](http://127.0.0.1:8095). - ### Worth to Note For all supported platforms, You can also use "pure" edition once you have [Python 3](https://www.python.org/) installed, or "bare" edition if you also managed to install `bleak` via `pip`. -Command line hackers? Just use `printer.py`! - See the [releases](https://github.com/NaitLee/Cat-Printer/releases) now! ## Problems? -Please open an issue if there's something in your mind! +Please talk in Discussion if there's something in your mind! -Of course PRs are welcome if you can handle them! +Of course Pull Requests are welcome if you can handle them! ## License @@ -125,7 +129,7 @@ Also interested in code development? See [development.md](development.md)! - Of course, Python & the Web! - [Bleak](https://bleak.readthedocs.io/en/latest/) BLE lib! The overall Hero! -- [roddeh-i18n](https://github.com/roddeh/i18njs), good work! +- [roddeh-i18n](https://github.com/roddeh/i18njs), the current built-in i18n is inspired by this - [python-for-android](https://python-for-android.readthedocs.io/en/latest/), though there are some painful troubles - [AdvancedWebView](https://github.com/delight-im/Android-AdvancedWebView) for saving my life from Java - Stack Overflow & the whole Internet, you let me know Android `Activity` all from beginning diff --git a/TODO b/TODO index 6ef2e8b..05ca044 100644 --- a/TODO +++ b/TODO @@ -1,26 +1,23 @@ Note: not ordered. do whatever I/you want -+ Check GB03, again -+ Hacky text printing, typewriter-like, with PF2 font - ok I won't forget frontend, but it's different -+ Better CLI, e.g. invoke imagemagick if input is not PBM -+ Consider better MTU adaption -+ Consider better BLE traffic regulation, with BLE notification -+ Consider the printer 'text' mode, it can make things faster -+ Consider more control to something like 'energy' -+ Consider keeping server backend (more) secure to be used by IPP -+ Clean up CUPS/IPP code -+ Make a build guide for android: - Summary the hacks to p4a, bleak p4a recipe, p4a webview bootstrap, and AdvancedWebView -+ More frontend usability, more functions -+ A better layout for mobile? -+ Better Canvas mode, (re-)consider fabric.js -+ Implement Document mode, (re-)consider html2canvas.js ++ Cookbook of basic things + Write good help/manual + Make error notice short while let users see detailed help/manual for what-to-do ++ Even Better CLI, e.g. invoke imagemagick if input is not PBM ++ Even better CUPS/IPP support ++ Even better frontend usability, more functions ++ A better layout for mobile? ++ Make a build guide for android: + Summary the hacks to p4a, bleak p4a recipe, p4a webview bootstrap, and AdvancedWebView ++ Try to implement enough without more dependencies ++ Better Canvas mode, (re-)consider fabric.js ++ Implement Document mode, (re-)consider html2canvas.js + ... -? Compression for GB03 data +? Consider more control to something like 'energy' + This have no effect on my GB02 +? Data compression for GB03. Optional ? Put Android APP on F-Droid? But it needs automatic build system... Android guys can help this! +? ... Or put to APKPure? diff --git a/additional/i18n.py b/additional/i18n.py deleted file mode 100644 index 06a4938..0000000 --- a/additional/i18n.py +++ /dev/null @@ -1,66 +0,0 @@ -'Minimal internationalization lib' - -import os -import math -import json -import locale - -class I18n(): - ''' Minimal implementation of current frontend i18n in Python - Not Complete (yet)! - ''' - - lang: str - fallback: str - data: dict = { - 'values': {}, - 'contexts': [] - } - - def __init__(self, search_path='lang', lang=None, fallback=None): - self.lang = lang or locale.getdefaultlocale()[0] - self.fallback = fallback or 'en_US' - self.load_file(os.path.join(search_path, self.fallback.replace('_', '-') + '.json')) - for name in os.listdir(search_path): - if name == self.lang.replace('_', '-') + '.json': - self.load_file(os.path.join(search_path, name)) - - def load_file(self, name): - 'Load an i18n json file' - with open(name, 'r', encoding='utf-8') as file: - self.load_data(file.read()) - - def load_data(self, raw_json): - 'Load i18n json data (from str)' - data = json.loads(raw_json) - for key in data['values']: - self.data['values'][key] = data['values'][key] - if data.get('contexts') is not None: - self.data['contexts'] = data['contexts'] - - def __getitem__(self, keys): - if not isinstance(keys, tuple): - keys = (keys, ) - data = self.data['values'].get(keys[0], keys[0]) - string = data[0][2] if isinstance(data, list) else data - for i in keys: - if isinstance(i, (int, float)): - if string is None: - string = data - if isinstance(data, list): - for j in data: - if j[0] is None: - j[0] = -math.inf - if j[1] is None: - j[1] = math.inf - if j[0] < i < j[1]: - template = j[2] - break - string = template.replace('%%n', i).replace('-%%n', -i) - elif isinstance(i, dict): - # not verified if would work - if string is None: - string = data - for j in i: - string = string.replace(f'%%{j}', i[j]) - return string diff --git a/build-android/0-build-android.sh b/build-android/0-build-android.sh index 4306364..e942098 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.1" --bootstrap=webview --window --requirements=android,pyjnius,bleak \ + --icon=icon.png --version="0.2.0" --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-android/3-formal-build.sh b/build-android/3-formal-build.sh index e139001..fe278b6 100755 --- a/build-android/3-formal-build.sh +++ b/build-android/3-formal-build.sh @@ -4,7 +4,7 @@ unzip -q "../cat-printer-bare-$1.zip" mv "cat-printer" "dist" p4a apk --private "dist" --dist_name="cat-printer" --package="io.github.naitlee.catprinter" --name="Cat Printer" \ --icon=icon.png --version="$1" --bootstrap=webview --window --requirements=android,pyjnius,bleak \ - --blacklist-requirements=sqlite3,openssl --port=8095 --arch=arm64-v8a \ + --blacklist-requirements=sqlite3,openssl --port=8095 --arch=arm64-v8a --release \ --presplash=blank.png --presplash-color=black --add-source="advancedwebview" --orientation=user \ --permission=BLUETOOTH --permission=BLUETOOTH_SCAN --permission=BLUETOOTH_CONNECT \ --permission=BLUETOOTH_ADMIN --permission=ACCESS_FINE_LOCATION --permission=ACCESS_COARSE_LOCATION diff --git a/build-common/bundle.py b/build-common/bundle.py index 29a836c..89d5a50 100644 --- a/build-common/bundle.py +++ b/build-common/bundle.py @@ -22,23 +22,20 @@ if not sys.argv[-1].startswith('-'): bundle_name %= (edition, version) ignore_whitelist = ( - 'www/i18n.js', 'www/main.comp.js' ) additional_ignore = ( # prevent recurse bundle_name, + # non-production (yet) + 'PKGBUILD', 'systemd', # build helpers - 'build-*', - '?-*.sh', + 'build-*', '?-*.sh', # no need - '.git', - '.vscode', - '.pylintrc', - '.gitignore', - 'dev-diary.txt', - 'TODO', + '.git', '.gitignore', + '.vscode', '.pylintrc', + 'dev-diary.txt', 'TODO', # cache '*.pyc', # other diff --git a/dev-diary.txt b/dev-diary.txt index c6d3247..ccc870a 100644 --- a/dev-diary.txt +++ b/dev-diary.txt @@ -55,3 +55,40 @@ It's finally ready... Documentation. What else? First Release! + +... ... + +(some day) + +Determined to make backend better, +but resulting in full rewrite. + +... ... (worked hard) + +15th + +Feeling it's there. + +Oh, asyncio always quits like a mad cat, throwing bleak there +and just ends everything, uncleanly. + +16th + +Solved many things left yesterday. + +Thought i18n needs be universal across there, so made one by myself. + +Tried to implement flip, rtl and wrap in text printing, +wasted some time, but didn't regret. + +Well, slightly update document and try compiling, +give everyone a surprise. + +Try --release on p4a build. It worked. 5.9MiB apk, satisfied now? +Don't forget -Djava.net.useSystemProxies=true on gradle anymore, +when a proxy to google is needed. + +Phone says a release apk should be signed to be installed. +Satisfy it. https://stackoverflow.com/questions/4853011/how-to-sign-an-android-apk-file + +Okay, it's 17th 3 a.m. publish it and sleep. diff --git a/development.md b/development.md index 31b6462..d95723f 100644 --- a/development.md +++ b/development.md @@ -1,6 +1,8 @@ # Development +**Note: Some maybe outdated at the moment** + ## Overview This application have a Client/Server module, but it's just locally. @@ -9,7 +11,6 @@ The backend is in Python 3, aiming to have fewest dependencies, and in fact curr This can ensure the simplicity of the core part. And the frontend is in a "old good" way, that use no "framework". -It needs [roddeh-i18n](https://www.npmjs.com/package/roddeh-i18n) lib for localization, and optionally [vConsole](https://www.npmjs.com/package/vconsole) for debugging on mobile. My workspace stack is Linux/GNU/Artix/KDE/VSCodium, if you're interested. For Android, GNU/Linux is required, though. @@ -27,12 +28,11 @@ Just clone this repo first! 1. Get Bleak BLE lib: `pip install bleak` -2. Grab i18n.js [here](https://github.com/roddeh/i18njs/tree/master/dist), put to `www` as `i18n.js` -You are already well done for basic development. See [files](#files) section for what all the files do. +Alright, you are already well done for basic development. See [files](#files) section for what all the files do. For more, read on... -### Additional +### Optional Sorry, I'm not a dev package manager enthusiast. @@ -48,19 +48,13 @@ If there are something better to organize these, feel free to discuss in issue. - Get an Windows 64-bit embeddable Python, extract to `build-common/python-win32-amd64-embed` - You may remove the "bloated" parts inside, notably `libssl`, `libcrypto`, `sqlite3` and `pydoc`, of both `dll`/`pyd` files and in `python.zip`, if have any. - Now you're able to bundle a "windows" edition, via `python3 bundle.py -w` -- Grab i18n.js typings `index.d.ts` from [here](https://github.com/roddeh/i18njs/tree/master/typings), put to `www` as `i18n.d.ts` - In the file, replace the last line: - `export = roddeh_i18n;` - with: - `declare var i18n = roddeh_i18n;` - Now you are ready to do more with i18n lib with the typing hint - Get a [vConsole](https://www.npmjs.com/package/vconsole) script, put to `www` as `vconsole.js` Now you're ready to debug in browsers without a dev panel, by double-tapping "Cat Printer" title in the UI ## Files - `server.py` - A Web server that: - - Is single threaded, to work with Android/pyjnius + - Is single threaded & with static handler, for some reasons - Serves static Web files, that are in folder `www` - Opens a Web browser once launched, unless specify the `-s` command-line parameter - Only listen to localhost, unless specify the `-a` command-line parameter @@ -83,6 +77,10 @@ If there are something better to organize these, feel free to discuss in issue. - Transpiled with TypeScript, for fallback on old browsers - Bundled all required scripts, see file `0-transpile.sh` - Is not there by default. Transpile it yourself +- `www/i18n*` - Scripts about I18n: + - TODO. In fact it worth a dedicated document to describe it + - (Mostly) Depends on "extensions" to work in the correct way, + feel free to extend, as it's *your* turn - `www/*.js` - Other scripts: - Small but useful, just look at them directly - `www/jslicense.html` - Dedicated JavaScript License information @@ -93,6 +91,7 @@ If there are something better to organize these, feel free to discuss in issue. - Quickly invoke with `./N` - `build-common/bundle.py` - Bundler for "windows", "pure" and "bare" editions - You can define what to include or not in this script, just modify directly, while trying to not alter other + - Adviced to transpile scripts before bundling - To do the builds you should be in the build dir: `cd build-common` - With `bleak` there you're able to bundle a "pure" edition via just `python3 bundle.py` - In any case you're able to bundle a "bare" edition, via `python3 bundle.py -b` diff --git a/printer.py b/printer.py index 57025a3..01e2d86 100644 --- a/printer.py +++ b/printer.py @@ -1,478 +1,584 @@ -'Cat-Printer' +'Cat-Printer: Core driver' +import os import io import sys import argparse import asyncio -from typing import List, Union, Any, Mapping -from bleak import BleakClient, BleakScanner -from bleak.exc import BleakError, BleakDBusError +import platform + +class ExitCodes(): + 'Exit codes' + Success = 0 + GeneralError = 1 + InvalidArgument = 2 + PrinterError = 64 + IncompleteProgram = 128 + MissingDependency = 129 + +def info(*args, **kwargs): + 'Just `print` to `stdout`' + print(*args, **kwargs, file=sys.stdout, flush=True) + +def error(*args, exception=None, **kwargs): + '`print` to `stderr`, or optionally raise an exception' + if exception is not None: + raise exception(*args) + else: + print(*args, **kwargs, file=sys.stderr, flush=True) + +def fatal(*args, code=ExitCodes.GeneralError, **kwargs): + '`print` to `stderr`, and exit with `code`' + print(*args, **kwargs, file=sys.stderr, flush=True) + sys.exit(code) + +# Do i18n first try: - from additional.i18n import I18n + from printer_lib.i18n import I18nLib + for path in ('www/lang', 'lang'): + if os.path.exists(path): + I18n = I18nLib(path) + break + else: # if didn't break + error('Warning: No languages were found', exception=None) except ImportError: - class I18n(): - 'Dummy i18n in case "full" version is missing' + fatal( + 'Folder "printer_lib" is incomplete or missing, please check.', + code=ExitCodes.IncompleteProgram + ) - def __init__(self, _search_path=None, _lang=None, _fallback=None): - pass +# Test if `pyobjc` is there on MacOS - def __getitem__(self, keys): - if not isinstance(keys, tuple): - keys = (keys, ) - return ' '.join([str(x) for x in keys]) +if platform.system() == 'macOS': + try: + import CoreBluetooth # pylint: disable=import-error,unused-import + except ImportError: + fatal( + I18n['please-install-pyobjc-via-pip'], + ' $ pip3 install pyobjc', + code=ExitCodes.MissingDependency + ) -i18n = I18n('www/lang') +# Test if `bleak` is there +try: + from bleak import BleakClient, BleakScanner + from bleak.backends.device import BLEDevice + from bleak.exc import BleakError, BleakDBusError +except ImportError: + fatal( + I18n['please-install-bleak-via-pip'], + ' $ pip3 install bleak', + code=ExitCodes.MissingDependency + ) + +# Import essential basic parts + +try: + from printer_lib.models import Models, Model + from printer_lib.commander import Commander, reverse_bits + from printer_lib.text_print import TextCanvas +except ImportError: + fatal( + I18n['folder-printer_lib-is-incomplete-or-missing-please-check'], + code=ExitCodes.IncompleteProgram + ) + +# Helpers + +def flip(buffer, width, height, horizontally=False, vertically=True, *, overwrite=False): + 'Flip the bitmap data' + buffer.seek(0) + if not horizontally and not vertically: + return buffer + data_width = width // 8 + result_0 = io.BytesIO() + if horizontally: + while data := buffer.read(data_width): + data = bytearray(map(reverse_bits, data)) + data.reverse() + result_0.write(data) + result_0.seek(0) + else: + result_0 = buffer + result_1 = io.BytesIO() + if vertically: + for i in range(height - 1, -1, -1): + result_0.seek(i * data_width) + data = result_0.read(data_width) + result_1.write(data) + result_1.seek(0) + else: + result_1 = result_0 + buffer.seek(0) + if overwrite: + while data := result_1.read(data_width): + buffer.write(data) + buffer.seek(0) + return result_1 + + +# Classes class PrinterError(Exception): - 'Error of Printer driver' + 'Exception raised when something went wrong during printing' + message: str + message_localized: str + def __init__(self, *args): + super().__init__(*args) + self.message = args[0] + self.message_localized = I18n[args] - -models = ('GT01', 'GB01', 'GB02', 'GB03') - -crc8_table = [ - 0x00, 0x07, 0x0e, 0x09, 0x1c, 0x1b, 0x12, 0x15, 0x38, 0x3f, 0x36, 0x31, - 0x24, 0x23, 0x2a, 0x2d, 0x70, 0x77, 0x7e, 0x79, 0x6c, 0x6b, 0x62, 0x65, - 0x48, 0x4f, 0x46, 0x41, 0x54, 0x53, 0x5a, 0x5d, 0xe0, 0xe7, 0xee, 0xe9, - 0xfc, 0xfb, 0xf2, 0xf5, 0xd8, 0xdf, 0xd6, 0xd1, 0xc4, 0xc3, 0xca, 0xcd, - 0x90, 0x97, 0x9e, 0x99, 0x8c, 0x8b, 0x82, 0x85, 0xa8, 0xaf, 0xa6, 0xa1, - 0xb4, 0xb3, 0xba, 0xbd, 0xc7, 0xc0, 0xc9, 0xce, 0xdb, 0xdc, 0xd5, 0xd2, - 0xff, 0xf8, 0xf1, 0xf6, 0xe3, 0xe4, 0xed, 0xea, 0xb7, 0xb0, 0xb9, 0xbe, - 0xab, 0xac, 0xa5, 0xa2, 0x8f, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9d, 0x9a, - 0x27, 0x20, 0x29, 0x2e, 0x3b, 0x3c, 0x35, 0x32, 0x1f, 0x18, 0x11, 0x16, - 0x03, 0x04, 0x0d, 0x0a, 0x57, 0x50, 0x59, 0x5e, 0x4b, 0x4c, 0x45, 0x42, - 0x6f, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7d, 0x7a, 0x89, 0x8e, 0x87, 0x80, - 0x95, 0x92, 0x9b, 0x9c, 0xb1, 0xb6, 0xbf, 0xb8, 0xad, 0xaa, 0xa3, 0xa4, - 0xf9, 0xfe, 0xf7, 0xf0, 0xe5, 0xe2, 0xeb, 0xec, 0xc1, 0xc6, 0xcf, 0xc8, - 0xdd, 0xda, 0xd3, 0xd4, 0x69, 0x6e, 0x67, 0x60, 0x75, 0x72, 0x7b, 0x7c, - 0x51, 0x56, 0x5f, 0x58, 0x4d, 0x4a, 0x43, 0x44, 0x19, 0x1e, 0x17, 0x10, - 0x05, 0x02, 0x0b, 0x0c, 0x21, 0x26, 0x2f, 0x28, 0x3d, 0x3a, 0x33, 0x34, - 0x4e, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5c, 0x5b, 0x76, 0x71, 0x78, 0x7f, - 0x6a, 0x6d, 0x64, 0x63, 0x3e, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2c, 0x2b, - 0x06, 0x01, 0x08, 0x0f, 0x1a, 0x1d, 0x14, 0x13, 0xae, 0xa9, 0xa0, 0xa7, - 0xb2, 0xb5, 0xbc, 0xbb, 0x96, 0x91, 0x98, 0x9f, 0x8a, 0x8d, 0x84, 0x83, - 0xde, 0xd9, 0xd0, 0xd7, 0xc2, 0xc5, 0xcc, 0xcb, 0xe6, 0xe1, 0xe8, 0xef, - 0xfa, 0xfd, 0xf4, 0xf3 -] - - -def crc8(data: Union[bytes, bytearray]): - 'crc8 hash' - crc = 0 - for byte in data: - crc = crc8_table[(crc ^ byte) & 0xFF] - return crc & 0xFF - - -def set_attr_if_not_none(obj: Any, attrs: Mapping[str, str]): - ''' set the attribute of `obj` if the value is not `None` - `attrs` is `dict` of attr-value pair +class PrinterData(): + ''' The image data to be used by `PrinterDriver`. + Optionally give an io `file` to read PBM image data from it. + To read the bitmap data, simply do `io` operation with attribute `data` ''' - for name in attrs: - value = attrs[name] - if value is not None: - setattr(obj, name, value) - -def reverse_binary(value: int): - 'Get the binary value of `value` and return the binary-reversed form of it as an `int`' - return int(f"{bin(value)[2:]:0>8}"[::-1], 2) - - -def make_command( - command: int, payload: Union[bytes, bytearray], *, - prefix: List[int] = None -) -> bytearray: - 'Make a `bytearray` with command data, which can be sent to printer directly to operate' - if len(payload) > 0x100: - raise Exception('Too large payload') - message = bytearray() - if prefix is not None: - message += prefix - message += bytearray([ - 0x51, 0x78, - command, 0x00, - len(payload), 0x00, - *payload, crc8(payload), - 0xFF - ]) - return message - - -class PrinterCommands(): - 'Constants of command flags used by the printer' - RetractPaper = 0xA0 # Data: Number of steps to go back - FeedPaper = 0xA1 # Data: Number of steps to go forward - # Data: Line to draw. 0 bit -> don't draw pixel, 1 bit -> draw pixel - DrawBitmap = 0xA2 - DrawingMode = 0xBE # Data: 1 for Text, 0 for Images - SetEnergy = 0xAF # Data: 1 - 0xFFFF - SetQuality = 0xA4 # Data: 1 - 5 - UpdateDevice = 0xA9 # Data: 0x00 - LatticeControl = 0xA6 - - -class PBMData(): - 'Extract/Serialize PBM data' width: int + 'Constant width' + _data_width: int + 'Amount of data bytes per line' height: int - data: bytes - args: dict - 'Note: going to put this in `PrinterDriver` in the future' + 'Total height of bitmap data' + data: bytearray + 'Monochrome bitmap data `io`, of size `width * height // 8`' + pages: list + 'Height of every page in a `list`' + max_size: int + 'Max size of `data`' + full: bool + 'Whether the data is full (i.e. have reached max size)' - def __init__(self, width: int, height: int, data: bytes, args: dict = None): + def __init__(self, width, file: io.BufferedIOBase=None, max_size=64 * 1024 * 1024): self.width = width - self.height = height - self.data = data - self.args = { - # setting to \x01 may make it faster. but don't know if there are drawbacks - PrinterCommands.DrawingMode: b'\x00', - PrinterCommands.SetEnergy: b'\xE0\x2E', - PrinterCommands.SetQuality: b'\x05' - } - if args: - for arg in args: - self.args[arg] = args[arg] + self._data_width = width // 8 + self.height = 0 + self.max_size = max_size + self.max_height = max_size // self._data_width + self.full = False + self.data = io.BytesIO() + self.pages = [] + if file is not None: + self.from_pbm(file) -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 + def write(self, data: bytearray): + ''' Directly write bitmap data to `data` directly. For memory safety, + will overwrite earliest data if going to reach `max_size`. + returns the io position after writing. + ''' + data_len = len(data) + if self.data.tell() + data_len > self.max_size: + self.full = True + self.data.seek(0) + self.data.write(data) + position = self.data.tell() + if not self.full: + self.height = position // self._data_width + return position -class PrinterDriver(): - 'Manipulator of the printer' + def read(self, length=-1): + ''' Read the bitmap data entirely, in chunks. + `yield` the resulting data. + Will finally put seek point to `0` + ''' + self.data.seek(0) + while chunk := self.data.read(length): + yield chunk + self.data.seek(0) - name: str = None - 'The Bluetooth name of the printer' - - address: str = None - 'The Bluetooth MAC address of the printer' - - frequency = 0.8 - ''' Time to wait between communication to printer, in seconds, - too low value will cause gaps/tearing of printed content, - while too high value will make printer slow/clumsy - ''' - feed_after = 128 - 'Extra paper to feed at the end of printing, by pixel' - - dry_run = None - 'Is dry run (emulate print process but print nothing)' - - 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(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 - - def _read_pbm(self, path: str = None, data: bytes = None): - if path is not None and path != '-': - file = open(path, 'rb') - data = file.read() - file.close() - elif data is not None: - pass - else: - data = sys.stdin.buffer.read() - if data[0:3] != b'P4\n': - raise Exception('Specified file is not a PBM image') - # there can be several "chunks", by e.g. cat-ing several files, or ghostscript output - chunks = data.split(b'P4\n')[1:] - result = b'' - total_height = 0 - for chunk in chunks: - page = io.BytesIO(chunk) + def from_pbm(self, file: io.BufferedIOBase): + ''' Read from buffer `file` that have PBM image data. + Concatenating multiple files *is* allowed. + Calling multiple times is also possible, + before or after yielding `read`, not between. + Will put seek point to last byte written. + ''' + while signature := file.readline(): + if signature != b'P4\n': + error('input-is-not-pbm-image', exception=PrinterError) while True: # There can be comments. Skip them - line = page.readline()[0:-1] + line = file.readline()[0:-1] if line[0:1] != b'#': break - width, height = [int(x) for x in line.split(b' ')[0:2]] - 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 - raw_data = page.read() + width, height = map(int, line.split(b' ')) + if width != self.width: + error( + 'unsuitable-image-width-expected-0-got-1', + self.width, width, + exception=PrinterError + ) + self.pages.append(height) + self.height += height + expected_data_size = self._data_width * height + raw_data = file.read(expected_data_size) data_size = len(raw_data) if data_size != expected_data_size: - raise Exception('Broken PBM file data') - if self.dry_run: - # Dry run: put blank data - result += b'\x00' * expected_data_size - else: - result += raw_data - return PBMData(self.paper_width, total_height, result) + error('broken-pbm-image', exception=PrinterError) + self.write(raw_data) + if self.full: + self.pages.pop(0) + if file is not sys.stdin.buffer: + file.close() - def _pbm_data_to_raw(self, data: PBMData): - buffer = bytearray() - # new/old print command - if self.name == 'GB03': - buffer.append(0x12) - buffer += bytearray([0x51, 0x78, 0xa3, 0x00, - 0x01, 0x00, 0x00, 0x00, 0xff]) - for key in data.args: - buffer += make_command(key, data.args[key]) - buffer += make_command( - PrinterCommands.LatticeControl, - bytearray([0xAA, 0x55, 0x17, 0x38, 0x44, - 0x5F, 0x5F, 0x5F, 0x44, 0x38, 0x2C]) - ) - for i in range(data.height): - data_for_a_line = data.data[ - i * self.pbm_data_per_line: - (i + 1) * self.pbm_data_per_line - ] - if i % 200 == 0: - buffer += make_command( - PrinterCommands.LatticeControl, - bytearray([0xAA, 0x55, 0x17, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x17]) - ) - # buffer += make_command( - # PrinterCommands.UpdateDevice, - # bytearray([0x00]) - # ) - buffer += make_command( - PrinterCommands.DrawBitmap, - bytearray([reverse_binary(x) for x in data_for_a_line]) - ) - buffer += make_command( - PrinterCommands.LatticeControl, - bytearray([0xAA, 0x55, 0x17, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x17]) - ) - if self.feed_after > 0: - buffer += make_command( - PrinterCommands.FeedPaper, - bytearray([self.feed_after % 256, self.feed_after // 256]) - ) - return buffer - - 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() - count = 0 - total = len(buffer) // self.mtu - while True: - start = count * self.mtu - end = count * self.mtu + self.mtu - if count < total: - await client.write_gatt_char(self.characteristic, buffer[start:end]) - if count % 16 == 0: - await asyncio.sleep(self.frequency) - count += 1 - else: - await client.write_gatt_char(self.characteristic, buffer[start:]) - break - await client.disconnect() - - async def search_all_printers(self, timeout: int): - ''' Search for all printers around with bluetooth. - Only known-working models will show up. + def to_pbm(self, *, merge_pages=False): + ''' `yield` the pages as PBM image data, + optionally just merge to one page. + Will restore the previous seek point. ''' - timeout = timeout or 3 - devices = await BleakScanner.discover(timeout) - result = [] - for device in devices: - if device.name in models: - result.append(device) - return result - - async def search_printer(self, timeout: int): - 'Search for a printer, returns `None` if not found' - timeout = timeout or 3 - devices = await self.search_all_printers(timeout) - if len(devices) != 0: - return devices[0] - return None - - async def print_file(self, path: str, address: str = None): - 'Method to print the specified PBM image at `path` with printer at specified MAC `address`' - address = address or self.address - pbm_data = self._read_pbm(path) - buffer = self._pbm_data_to_raw(pbm_data) - await self.send_buffer(buffer, address) - - async def print_data(self, data: bytes, address: str = None): - 'Method to print the specified PBM image `data` with printer at specified MAC `address`' - address = address or self.address - pbm_data = self._read_pbm(None, data) - buffer = self._pbm_data_to_raw(pbm_data) - await self.send_buffer(buffer, address) - - async def filter_device(self, info: str, timeout: float = 5.0) -> bool: - 'Find a suitable device with `info`: Bluetooth name or MAC address, or empty string' - devices = await self.search_all_printers(timeout) - if len(devices) == 0: - return False - if info in models: - for device in devices: - if device.name == info: - self.name, self.address = device.name, device.address - break - 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 - break + pointer = self.data.tell() + self.data.seek(0) + if merge_pages: + yield bytearray( + b'P4\n%i %i\n' % (self.width, self.height) + ) + self.data.read() else: - device = devices[0] - self.name, self.address = device.name, device.address - return True + for i in self.pages: + yield bytearray( + b'P4\n%i %i\n' % (self.width, i) + ) + self.data.read(self._data_width * i) + self.data.seek(pointer) - async def print_text(self, file_io: io.IOBase): - 'Print some text from `file_io`' + def __del__(self): + self.data.truncate(0) + self.data.close() + del self.data + +# The driver + +class PrinterDriver(Commander): + 'The core driver of Cat-Printer' + + device: BleakClient = None + 'The connected printer device.' + + model: Model = None + 'The printer model' + + scan_timeout: float = 5.0 + + connection_timeout : float = 5.0 + + text_canvas: TextCanvas = None + flip_h: bool = False + flip_v: bool = False + wrap: bool = False + rtl: bool = False + + mtu: int = 200 + + tx_characteristic = '0000ae01-0000-1000-8000-00805f9b34fb' + rx_characteristic = '0000ae02-0000-1000-8000-00805f9b34fb' + + dry_run: bool = False + 'Test print process only, will not waste paper' + + fake: bool = False + 'Test data logic only, will not waste time' + + dump: bool = False + 'Dump traffic data, and if it\'s text printing, the resulting PBM image' + + _loop: asyncio.AbstractEventLoop = None + + _traffic_dump: io.FileIO = None + + _paused: bool = False + + _pending_data: io.BytesIO = None + + def __init__(self): + self._loop = asyncio.get_event_loop_policy().get_event_loop() + + def loop(self, *futures): + ''' Run coroutines in order in current event loop until complete, + return its result directly, or their result as tuple + ''' + results = [] + for future in futures: + results.append(self._loop.run_until_complete(future)) + return results[0] if len(results) == 1 else tuple(results) + + def connect(self, name=None, address=None): + ''' Connect to this device, and operate on it + ''' + if self.fake: + return + if (self.device is not None and address is not None and + (self.device.address.lower() == address.lower())): + return + if self.device is not None and self.device.is_connected: + self.loop( + self.device.stop_notify(self.rx_characteristic), + self.device.disconnect() + ) + else: + self.device = None + if name is None and address is None: + return + self.model = Models[name] + self.device = BleakClient(address) + def notify(_char, data): + if data == b'\x51\x78\xae\x01\x01\x00\x10\x70\xff': + self._paused = True + elif data == b'\x51\x78\xae\x01\x01\x00\x00\x00\xff': + self._paused = False + self.loop( + self.device.connect(timeout=self.connection_timeout), + self.device.start_notify(self.rx_characteristic, notify) + ) + + def scan(self, identifier: str=None, *, use_result=False): + ''' Scan for supported devices, optionally filter with `identifier`, + which can be device model (bluetooth name), and optionally MAC address, after a comma. + If `use_result` is True, connect to the first available device to driver instantly. + Note: MAC address doesn't work on Apple MacOS. In place with it, + You need an UUID of BLE device dynamically given by MacOS. + ''' + if self.fake: + return + if identifier is not None: + if identifier.find(',') != -1: + name, address = identifier.split(',') + if name not in Models: + error('model-0-is-not-supported-yet', name, exception=PrinterError) + if address[2::3] != ':::::' and len(address.replace('-', '')) != 32: + error('invalid-address-0', address, exception=PrinterError) + if use_result: + self.connect(name, address) + return [BLEDevice(address, name)] + elif (identifier not in Models and + identifier[2::3] != ':::::' and len(identifier.replace('-', '')) != 32): + error('model-0-is-not-supported-yet', identifier, exception=PrinterError) + scanner = BleakScanner() + devices = self.loop( + scanner.discover(self.scan_timeout) + ) + if identifier is not None: + if identifier in Models: + devices = [dev for dev in devices if dev.name == identifier] + else: + devices = [dev for dev in devices if dev.address.lower() == identifier.lower()] + if use_result and len(devices) != 0: + self.connect(devices[0].name, devices[0].address) + return devices + + def print(self, file: io.BufferedIOBase, *, mode='default', + identifier: str=None): + ''' Print data of `file`. + Currently, available modes are `pbm` and `text`. + If no devices were connected, scan & connect to one first. + ''' + if self.device is None: + self.scan(identifier, use_result=True) + if self.device is None and not self.fake: + error('no-available-devices-found', exception=PrinterError) + self._pending_data = io.BytesIO() + if mode == 'pbm' or mode == 'default': + printer_data = PrinterData(self.model.paper_width, file) + self._print_bitmap(printer_data) + elif mode == 'text': + self._print_text(file) + else: + ... # TODO: other? + + def flush(self): + 'Send pending data instantly, but will block if paused' + self._pending_data.seek(0) + while chunk := self._pending_data.read(self.mtu): + while self._paused: + self.loop(asyncio.sleep(0.2)) + self.loop( + self.device.write_gatt_char(self.tx_characteristic, chunk), + asyncio.sleep(0.02) + ) + self._pending_data.seek(0) + self._pending_data.truncate() + + def send(self, data): + ''' Pend `data`, send if enough size is reached. + You can manually `flush` to send data instantly, + and should do `flush` at the end of printing. + ''' + if self.dump: + if self._traffic_dump is None: + self._traffic_dump = open('traffic.dump', 'wb') + self._traffic_dump.write(data) + if self.fake: + return + self._pending_data.write(data) + if self._pending_data.tell() > self.mtu * 16 and not self._paused: + self.flush() + + def _print_bitmap(self, data: PrinterData): + paper_width = self.model.paper_width + flip(data.data, data.width, data.height, self.flip_h, self.flip_v, overwrite=True) + if self.model.is_new_kind: + self.start_printing_new() + else: + self.start_printing() + self.image_mode() + # TODO: specify other commands + self.start_lattice() + # TODO: consider compression on new devices + for chunk in data.read(paper_width // 8): + if self.dry_run: + chunk = b'\x00' * len(chunk) + self.draw_bitmap(chunk) + self.end_lattice() + # TODO: adjustable + self.feed_paper(128) + self.flush() + + def _print_text(self, file: io.BufferedIOBase): + paper_width = self.model.paper_width + text_io = io.TextIOWrapper(file, encoding='utf-8') 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) + self.text_canvas = TextCanvas(paper_width, wrap=self.wrap, rtl=self.rtl) + if file is sys.stdin.buffer: + pf2 = self.text_canvas.pf2 + info(I18n['text-printing-mode']) + info(I18n['font-size-0', pf2.point_size]) + width_stats = {} + for i in range(0x20, 0x7f): + char = chr(i) + width_stats[char] = pf2[char].width + average = pf2.point_size // 2 + if (width_stats[' '] == width_stats['i'] == + width_stats['m'] == width_stats['M']): + average = width_stats['A'] + else: + average = (width_stats['a'] + width_stats['A'] + + width_stats['0'] + width_stats['+']) // 4 + info('-------+' * (paper_width // average // 8) + + '-' * (paper_width // average % 8)) + if self.model.is_new_kind: + self.start_printing_new() + else: + self.start_printing() + self.text_mode() + # TODO: specify other commands + self.start_lattice() + printer_data = PrinterData(paper_width) + buffer = io.BytesIO() + try: + while line := text_io.readline(): + if '\x00' in line: + error('input-is-not-text-file', exception=PrinterError) + line_count = 0 + for data in self.text_canvas.puttext(line): + buffer.write(data) + line_count += 1 + flip(buffer, self.text_canvas.width, self.text_canvas.height * line_count, + self.flip_h, self.flip_v, overwrite=True) + while chunk := buffer.read(paper_width // 8): + if self.dry_run: + chunk = b'\x00' * len(chunk) + self.draw_bitmap(chunk) + printer_data.write(chunk) + buffer.seek(0) + buffer.truncate() + self.flush() + except UnicodeDecodeError: + error('input-is-not-text-file', exception=PrinterError) + if self.dump: + with open('dump.pbm', 'wb') as dump_pbm: + dump_pbm.write(next(printer_data.to_pbm(merge_pages=True))) + self.end_lattice() + # TODO: adjustable + self.feed_paper(128) + self.flush() + def unload(self): + ''' Unload this instance, disconnect device and clean up. + ''' + if self.device is not None: + info(I18n['disconnecting-from-printer']) + try: + self.loop( + self.device.stop_notify(self.rx_characteristic), + self.device.disconnect() + ) + except BleakError: + self.device = None + if self._traffic_dump is not None: + self._traffic_dump.close() + self._loop.close() -async def _main(): +# CLI procedure + +def _main(): 'Main routine for direct command line execution' parser = argparse.ArgumentParser( description=' '.join([ - i18n['print-pbm-image-to-cat-printer'], - i18n['supported-models-'], - str(models) + I18n['print-to-cat-printer'], + I18n['supported-models-'], + str((*Models, )) ]) ) + # TODO: group some switches to dedicated help parser.add_argument('file', default='-', metavar='FILE', type=str, - help=i18n['path-to-pbm-file-dash-for-stdin']) - exgr = parser.add_mutually_exclusive_group() - exgr.add_argument('-s', '--scan', metavar='TIME', default=3.0, required=False, type=float, - help=i18n['scan-for-specified-seconds']) - exgr.add_argument('-a', '--address', metavar='xx:xx:xx:xx:xx:xx', required=False, type=str, - help=i18n['specify-printer-mac-address']) - parser.add_argument('-f', '--freq', metavar='FREQ', required=False, type=float, - 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']) + help=I18n['path-to-input-file-dash-for-stdin']) + parser.add_argument('-s', '--scan', metavar='TIME', default=3.0, required=False, type=float, + help=I18n['scan-for-specified-seconds']) + parser.add_argument('-i', '--identifier', metavar='[XY01[,MAC]]', + required=False, type=str, + help=I18n['match-printer-with-this-name-or-address']) 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 + help=I18n['text-printing-mode']) + parser.add_argument('-w', '--wrap', required=False, action='store_true', + help=I18n['auto-wrap-line']) + parser.add_argument('-z', '--rtl', required=False, action='store_true', + help=I18n['right-to-left-text-order']) + parser.add_argument('-x', '--fliph', required=False, action='store_true', + help=I18n['flip-horizontally']) + parser.add_argument('-y', '--flipv', required=False, action='store_true', + help=I18n['flip-vertically']) + parser.add_argument('-d', '--dry', required=False, action='store_true', + help=I18n['dry-run-test-print-process-only']) + parser.add_argument('-f', '--fake', metavar='XY01', required=False, type=str, default='', + help=I18n['virtual-run-on-specified-model']) + parser.add_argument('-m', '--dump', required=False, action='store_true', + help=I18n['dump-the-traffic']) + args = parser.parse_args() + info(I18n['cat-printer']) printer = PrinterDriver() - if not addr: - print(i18n['cat-printer']) - print(i18n['scanning-for-devices']) - device = await printer.search_printer(cmdargs.scan) - if device is not None: - print(i18n['printing']) - else: - print(i18n['no-available-devices-found']) - print(i18n['please-check-if-the-printer-is-down']) - print(i18n['or-try-to-scan-longer']) - sys.exit(1) - if cmdargs.dry: - print(i18n['dry-run']) - set_attr_if_not_none(printer, { - 'name': device.name, - 'address': device.address, - 'frequency': cmdargs.freq, - 'dry_run': cmdargs.dry, - 'dump': cmdargs.dump - }) - 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() + printer.scan_timeout = args.scan + printer.flip_h = args.fliph + printer.flip_v = args.flipv + printer.wrap = args.wrap + printer.rtl = args.rtl + if args.dry: + info(I18n['dry-run-test-print-process-only']) + printer.dry_run = args.dry + if args.fake: + printer.fake = args.fake + printer.model = Models[args.fake] + printer.dump = args.dump + if args.file == '-': + file = sys.stdin.buffer else: - await printer.print_file(cmdargs.file) - print(i18n['finished']) + file = open(args.file, 'rb') + try: + info(I18n['connecting']) + printer.print( + file, + mode = 'text' if args.text else 'pbm', + identifier = args.identifier + ) + info(I18n['finished']) + except KeyboardInterrupt: + info(I18n['stopping']) + finally: + file.close() + printer.unload() - -async def main(): +def main(): 'Run the `_main` routine while catching exceptions' try: - await _main() + _main() except BleakError as e: error_message = str(e) if ( @@ -480,10 +586,11 @@ async def main(): (isinstance(e, BleakDBusError) and getattr(e, 'dbus_error') == 'org.bluez.Error.NotReady') ): - print(i18n['please-enable-bluetooth']) - sys.exit(1) + fatal(I18n['please-enable-bluetooth'], code=ExitCodes.GeneralError) else: raise + except PrinterError as e: + fatal(e.message_localized, code=ExitCodes.PrinterError) if __name__ == '__main__': - asyncio.run(main()) + main() diff --git a/additional/__init__.py b/printer_lib/__init__.py similarity index 100% rename from additional/__init__.py rename to printer_lib/__init__.py diff --git a/printer_lib/commander.py b/printer_lib/commander.py new file mode 100644 index 0000000..05020f8 --- /dev/null +++ b/printer_lib/commander.py @@ -0,0 +1,133 @@ +'Printer Commander' + +from abc import ABCMeta, abstractmethod + +crc8_table = [ + 0x00, 0x07, 0x0e, 0x09, 0x1c, 0x1b, 0x12, 0x15, 0x38, 0x3f, 0x36, 0x31, + 0x24, 0x23, 0x2a, 0x2d, 0x70, 0x77, 0x7e, 0x79, 0x6c, 0x6b, 0x62, 0x65, + 0x48, 0x4f, 0x46, 0x41, 0x54, 0x53, 0x5a, 0x5d, 0xe0, 0xe7, 0xee, 0xe9, + 0xfc, 0xfb, 0xf2, 0xf5, 0xd8, 0xdf, 0xd6, 0xd1, 0xc4, 0xc3, 0xca, 0xcd, + 0x90, 0x97, 0x9e, 0x99, 0x8c, 0x8b, 0x82, 0x85, 0xa8, 0xaf, 0xa6, 0xa1, + 0xb4, 0xb3, 0xba, 0xbd, 0xc7, 0xc0, 0xc9, 0xce, 0xdb, 0xdc, 0xd5, 0xd2, + 0xff, 0xf8, 0xf1, 0xf6, 0xe3, 0xe4, 0xed, 0xea, 0xb7, 0xb0, 0xb9, 0xbe, + 0xab, 0xac, 0xa5, 0xa2, 0x8f, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9d, 0x9a, + 0x27, 0x20, 0x29, 0x2e, 0x3b, 0x3c, 0x35, 0x32, 0x1f, 0x18, 0x11, 0x16, + 0x03, 0x04, 0x0d, 0x0a, 0x57, 0x50, 0x59, 0x5e, 0x4b, 0x4c, 0x45, 0x42, + 0x6f, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7d, 0x7a, 0x89, 0x8e, 0x87, 0x80, + 0x95, 0x92, 0x9b, 0x9c, 0xb1, 0xb6, 0xbf, 0xb8, 0xad, 0xaa, 0xa3, 0xa4, + 0xf9, 0xfe, 0xf7, 0xf0, 0xe5, 0xe2, 0xeb, 0xec, 0xc1, 0xc6, 0xcf, 0xc8, + 0xdd, 0xda, 0xd3, 0xd4, 0x69, 0x6e, 0x67, 0x60, 0x75, 0x72, 0x7b, 0x7c, + 0x51, 0x56, 0x5f, 0x58, 0x4d, 0x4a, 0x43, 0x44, 0x19, 0x1e, 0x17, 0x10, + 0x05, 0x02, 0x0b, 0x0c, 0x21, 0x26, 0x2f, 0x28, 0x3d, 0x3a, 0x33, 0x34, + 0x4e, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5c, 0x5b, 0x76, 0x71, 0x78, 0x7f, + 0x6a, 0x6d, 0x64, 0x63, 0x3e, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2c, 0x2b, + 0x06, 0x01, 0x08, 0x0f, 0x1a, 0x1d, 0x14, 0x13, 0xae, 0xa9, 0xa0, 0xa7, + 0xb2, 0xb5, 0xbc, 0xbb, 0x96, 0x91, 0x98, 0x9f, 0x8a, 0x8d, 0x84, 0x83, + 0xde, 0xd9, 0xd0, 0xd7, 0xc2, 0xc5, 0xcc, 0xcb, 0xe6, 0xe1, 0xe8, 0xef, + 0xfa, 0xfd, 0xf4, 0xf3 +] + +def crc8(data): + 'crc8 checksum' + crc = 0 + for byte in data: + crc = crc8_table[(crc ^ byte) & 0xff] + return crc & 0xff + +def reverse_bits(i: int): + 'Reverse the bits of this byte (as `int`)' + return ( + (i & 0b10000000) >> 7 | + (i & 0b01000000) >> 5 | + (i & 0b00100000) >> 3 | + (i & 0b00010000) >> 1 | + (i & 0b00001000) << 1 | + (i & 0b00000100) << 3 | + (i & 0b00000010) << 5 | + (i & 0b00000001) << 7 + ) + +def int_to_bytes(i: int, big_endian=False): + ''' Turn `int` into `bytearray`, that have + least bytes possible to represent the int + ''' + result = bytearray() + while i != 0: + result.append(i & 0xff) + i >>= 8 + if big_endian: + result.reverse() + return result + +class Commander(metaclass=ABCMeta): + 'Semi-abstract class, to be inherited by `PrinterDriver`' + + dry_run: bool = False + + def make_command(self, command_bit, payload: bytearray, *, + prefix=bytearray(), suffix=bytearray()): + 'Make bytes that to be used to control printer' + payload_size = len(payload) + if payload_size > 0xff: + raise ValueError(f'Command payload too big ({payload_size} > 255)') + return prefix + bytearray( + [ 0x51, 0x78, command_bit, 0x00, payload_size, 0x00 ] + ) + payload + bytearray( [ crc8(payload), 0xff ] ) + suffix + + def start_printing(self): + 'Start printing' + self.send( bytearray([0x51, 0x78, 0xa3, 0x00, 0x01, 0x00, 0x00, 0x00, 0xff]) ) + + def start_printing_new(self): + 'Start printing on newer printers' + self.send( bytearray([0x12, 0x51, 0x78, 0xa3, 0x00, 0x01, 0x00, 0x00, 0x00, 0xff]) ) + + def image_mode(self): + 'Enable Image Mode on the printer. Lighter, slower.' + self.send( self.make_command(0xbe, int_to_bytes(0x00)) ) + + def text_mode(self): + 'Enable Text Mode on the printer. Darker, faster.' + self.send( self.make_command(0xbe, int_to_bytes(0x01)) ) + + def update_device(self): + '(unknown)' + self.send( self.make_command(0xa9, int_to_bytes(0x00)) ) + + def start_lattice(self): + 'Start rolling paper' + self.send( self.make_command(0xa6, bytearray( + [0xaa, 0x55, 0x17, 0x38, 0x44, 0x5f, 0x5f, 0x5f, 0x44, 0x38, 0x2c] + )) ) + + def end_lattice(self): + 'End rolling paper' + self.send( self.make_command(0xa6, bytearray( + [ 0xaa, 0x55, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x17 ] + )) ) + + def retract_paper(self, steps: int): + 'Retract the paper for `steps`' + self.send( self.make_command(0xa0, int_to_bytes(steps)) ) + + def feed_paper(self, steps: int): + 'Feed the paper for `steps`' + self.send( self.make_command(0xa1, int_to_bytes(steps)) ) + + def set_energy(self, amount: int): + 'Set thermal energy, max to `0xffff`' + self.send( self.make_command(0xbe, int_to_bytes(amount)) ) + + def draw_bitmap(self, bitmap_data: bytearray): + 'Print `bitmap_data`. Also does the bit-reversing job.' + data = bytearray( map(reverse_bits, bitmap_data) ) + self.send( self.make_command(0xa2, data) ) + + def draw_compressed_bitmap(self, bitmap_data: bytearray): + 'TODO. Print `bitmap_data`, compress if worthy so' + self.draw_bitmap(bitmap_data) + + @abstractmethod + def send(self, data): + 'Send data to device, or whatever' + ... diff --git a/printer_lib/i18n.py b/printer_lib/i18n.py new file mode 100644 index 0000000..6c6f2be --- /dev/null +++ b/printer_lib/i18n.py @@ -0,0 +1,38 @@ +'Minimal internationalization lib' + +import os +import json +import locale + +class I18nLib(): + ''' Minimal implementation of current frontend i18n in Python. + Not Complete (yet)! + ''' + + lang: str + fallback: str + data: dict = {} + + def __init__(self, search_path='lang', lang=None, fallback=None): + self.lang = lang or locale.getdefaultlocale()[0].replace('_', '-') + self.fallback = fallback or 'en-US' + with open(os.path.join(search_path, self.fallback + '.json'), + 'r', encoding='utf-8') as file: + self.data = json.load(file) + path = self.lang + '.json' + if path in os.listdir(search_path): + with open(os.path.join(search_path, path), 'r', encoding='utf-8') as file: + data = json.load(file) + for key in data: + self.data[key] = data[key] + + def __getitem__(self, keys): + if not isinstance(keys, tuple): + keys = (keys, ) + string = self.data.get(keys[0], keys[0]) + if len(keys) > 1: + if isinstance(keys[-1], dict): + string = string.format(*keys[1:-1], **keys[-1]) + else: + string = string.format(*keys[1:]) + return string diff --git a/additional/ipp.py b/printer_lib/ipp.py similarity index 51% rename from additional/ipp.py rename to printer_lib/ipp.py index aab8d51..9d5fec9 100644 --- a/additional/ipp.py +++ b/printer_lib/ipp.py @@ -1,56 +1,45 @@ ''' Provide *very* basic CUPS/IPP support - Extracted from version 0.0.2, do more cleaning later... ''' +import io import platform import subprocess +from .pf2 import int16be, int32be + +def int8(b: bytes): + 'Translate 1 byte as signed 8-bit int' + u = b[0] + return u - ((u >> 7 & 0b1) << 8) + class IPP(): 'https://datatracker.ietf.org/doc/html/rfc8010' server = None - printer = None - def __init__(self, server, printer): + def __init__(self, server): self.server = server - self.printer = printer - async def handle_ipp(self, data): + def handle_ipp(self): 'Handle an IPP protocol request' server = self.server - # len_data = len(data) - # ipp_version_number = data[0:2] - # ipp_operation_id = data[2:4] - # ipp_request_id = data[4:8] - ipp_operation_attributes_tag = data[8] + content_length = int(server.headers.get('Content-Length')) + buffer = io.BytesIO(server.rfile.read(content_length)) + _ipp_version = (int8(buffer.read(1)), int8(buffer.read(1))) + _ipp_operation_id = int16be(buffer.read(2)) + _ipp_request_id = int32be(buffer.read(4)) + ipp_operation_attributes_tag = int8(buffer.read(1)) attributes = {} - data_to_print = b'' - # this is silly. i want to use io.BytesIO + data = b'' if ipp_operation_attributes_tag == 0x01: - pointer = 9 - next_name_length_at = 10 - next_value_length_at = 10 - name = b'' - value = b'' - while data[pointer] != 0x03: - tag = data[pointer:pointer + 1] - pointer += 1 - if tag[0] < 0x10: # delimiter-tag + while int8(buffer.read(1)) != 0x03: + buffer.seek(-1, 1) + tag = int8(buffer.read(1)) + if tag < 0x10: # delimiter-tag continue - next_name_length_at = pointer + data[pointer] * 0x0100 + data[pointer + 1] + 2 - pointer += 2 - while pointer < next_name_length_at: - name = name + data[pointer:pointer + 1] - pointer += 1 - next_value_length_at = pointer + data[pointer] * 0x0100 + data[pointer + 1] + 2 - pointer += 2 - while pointer < next_value_length_at: - value = value + data[pointer:pointer + 1] - pointer += 1 + name = buffer.read(int16be(buffer.read(2))) + value = buffer.read(int16be(buffer.read(2))) attributes[name] = (tag, value) - name = b'' - value = b'' - pointer += 1 - data_to_print = data[pointer:] + data = buffer.read() # there are hard coded minimal response. this "just works" on cups - if data_to_print == b'': + if data == b'': try: server.send_response(200) server.send_header('Content-Type', 'application/ipp') @@ -61,6 +50,14 @@ class IPP(): except BrokenPipeError: pass return + if data.startswith(b'%!PS-Adobe'): + self.handle_postscript(data) + else: + identifier = server.path[1:] + server.printer.print(io.BytesIO(data), mode='text', identifier=identifier) + def handle_postscript(self, data): + 'Print PostScript data to printer, converting to PBM first with GhostScript `gs`' + server = self.server platform_system = platform.system() # https://ghostscript.com/doc/9.54.0/Use.htm#Output_device if platform_system == 'Windows': @@ -76,15 +73,12 @@ class IPP(): '-dFitPage', '-dFitPage', '-sOutputFile=-', '-' ], executable=gsexe, stdin=subprocess.PIPE, stdout=subprocess.PIPE) - pbm_data, _ = gsproc.communicate(data_to_print) + pbm_data, _ = gsproc.communicate(data) try: if gsproc.wait() == 0: - info = server.path[1:] - is_found = await server.printer.filter_device(info, server.settings.scan_time) - if not is_found: - ... # TODO: Make IPP can report some errors - raise Exception(f'No printer found with info: {info}') - await server.printer.print_data(pbm_data) + identifier = server.path[1:] + # TODO: Make IPP can report some errors + server.printer.print(io.BytesIO(pbm_data), mode='pbm', identifier=identifier) else: raise Exception('Error on invoking Ghostscript') server.send_response(200) @@ -97,4 +91,3 @@ class IPP(): server.send_response(500) server.send_header('Content-Type', 'application/ipp') server.end_headers() - server.wfile.write(b'') diff --git a/printer_lib/models.py b/printer_lib/models.py new file mode 100644 index 0000000..fc8d578 --- /dev/null +++ b/printer_lib/models.py @@ -0,0 +1,16 @@ +'Printer model specifications' + +class Model(): + 'A printer model' + paper_width: int + is_new_kind: bool + def __init__(self, width, is_new): + self.paper_width = width + self.is_new_kind = is_new + +Models = { + 'GB01': Model(384, False), + 'GB02': Model(384, False), + 'GB03': Model(384, True), + 'GT01': Model(384, False), +} diff --git a/additional/pf2.py b/printer_lib/pf2.py similarity index 84% rename from additional/pf2.py rename to printer_lib/pf2.py index 1112865..09f8a92 100644 --- a/additional/pf2.py +++ b/printer_lib/pf2.py @@ -1,4 +1,7 @@ -'Python lib to read PF2 font file: http://grub.gibibit.com/New_font_format' +''' Python lib for reading PF2 font files: http://grub.gibibit.com/New_font_format + I'd like to put it in Public Domain. + Don't forget to see how it's used in `text_print.py` +''' import io from typing import Dict, Tuple @@ -57,12 +60,16 @@ class PF2(): descent: int character_index: Dict[int, Tuple[int, int]] data_offset: int - data_io: io.IOBase + data_io: io.BufferedIOBase - def __init__(self, path='font.pf2', *, read_to_mem=False, missing_character: str='?'): + def __init__(self, path='font.pf2', *, read_to_mem=True, missing_character: str='?'): self.missing_character_code = ord(missing_character) self.in_memory = read_to_mem file = open(path, 'rb') + if read_to_mem: + self.data_io = io.BytesIO(file.read()) + file.close() + file = self.data_io self.is_pf2 = (file.read(12) == b'FILE\x00\x00\x00\x04PFF2') if not self.is_pf2: return @@ -80,13 +87,7 @@ class PF2(): ) 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 + file.seek(0) break data = file.read(data_length) if name == b'NAME': @@ -116,7 +117,7 @@ class PF2(): info = self.character_index[self.missing_character_code] _compression, offset = info data = self.data_io - data.seek(offset + self.data_offset) + data.seek(offset) char = Character() char.width = uint16be(data.read(2)) char.height = uint16be(data.read(2)) @@ -130,7 +131,5 @@ class PF2(): __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() + def __del__(self): + self.data_io.close() diff --git a/printer_lib/text_print.py b/printer_lib/text_print.py new file mode 100644 index 0000000..cd506d6 --- /dev/null +++ b/printer_lib/text_print.py @@ -0,0 +1,87 @@ +'Things used by Text Printing feature' + +from .pf2 import PF2 + +class TextCanvas(): + 'Canvas for text printing, requires PF2 lib' + width: int + height: int + canvas: bytearray = None + rtl: bool + wrap: bool + pf2 = None + def __init__(self, width, *, wrap=False, rtl=False): + self.pf2 = PF2() + self.width = width + self.wrap = wrap + self.rtl = rtl + 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. + It's a generator, will `yield` the data produced, per line. + ''' + text = text.replace('\t', ' ' * 4) + canvas_length = len(self.canvas) + pf2 = self.pf2 + current_width = 0 + last_space_at = -1 + width_at_last_space = 0 + break_points = set() + characters = {} + for i, s in enumerate(text): + if s not in characters: + characters[s] = pf2[s] + char = characters[s] + if s == ' ': + last_space_at = i + width_at_last_space = current_width + if (current_width > self.width and + last_space_at != -1): + break_points.add(last_space_at) + current_width -= width_at_last_space + if s == '\n': + current_width = 0 + continue + current_width += pf2.point_size // 2 # + char.x_offset + current_width = 0 + for i, s in enumerate(text): + char = characters[s] + if (i in break_points or s == '\n' or + current_width + char.width > self.width): + # print(current_width, end=' ') + yield self.flush_canvas() + current_width = 0 + if s in ' ': + continue + if ord(s) in range(0x00, 0x20): # glyphs that should not be printed out + continue + for x in range(char.width): + for y in range(char.height): + rtl_current_width = self.width - current_width - char.width - 1 + 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 self.rtl: + canvas_byte = (self.width * target_y + rtl_current_width + target_x) // 8 + canvas_bit = 7 - (self.width * target_y + rtl_current_width + target_x) % 8 + else: + 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 diff --git a/readme.i18n/README.de_DE.md b/readme.i18n/README.de_DE.md index 74612b8..3526522 100644 --- a/readme.i18n/README.de_DE.md +++ b/readme.i18n/README.de_DE.md @@ -9,27 +9,16 @@ Gegenwärtig: -| | | -|-------------|-------------------| -| Supported | GB01, GB02, GT01 | -| Maybe | GB03 | -| Planned | N/A | +| | | +|----|----| +| Supported | GB01, GB02, GT01, GB03 | + + ## Funktionen *Derzeit befindet sich die Software im Alpha-Stadium. Mehr wird es bald geben!* -| Available | Partial | Planned | -|-----------------|-----------|---------------| -| Web Interface | CUPS/IPP* | Visual Editor | -| Print a Picture | | Help/Manual | -| Command-line | | Text Printing | - - -\* In development code. Will be released in a short period. - -*Along with…* - - Simple! - Bedienung über eine Web-UI direkt im Browser, - oder besorgen Sie sich die Android-Version! diff --git a/readme.i18n/README.zh_CN.md b/readme.i18n/README.zh_CN.md index e0e91dc..dd3b406 100644 --- a/readme.i18n/README.zh_CN.md +++ b/readme.i18n/README.zh_CN.md @@ -9,27 +9,14 @@ 目前有: -| | | -|-----------|-------------------| -| 支持 | GB01, GB02, GT01 | -| 可能支持 | GB03 | -| 计划 | 暂无 | +| | | +|----|----| +| 支持 | GB01, GB02, GT01, GB03 | ## 特性 *当前仍在继续开发。以后会有更多!* -| 可用 | 部分 | 计划 | -|-----------|-----------|---------------| -| 网页界面 | CUPS/IPP* | 可视化编辑器 | -| 打印图片 | | 帮助/文档 | -| 命令行 | | 文本打印 | - - -\* 存在于开发代码中。将在短时间内发布。 - -*当然还有……* - - 简易! - 在网页界面进行操作, - 或者获取安卓应用! @@ -38,6 +25,17 @@ - 语言支持!您可参与翻译! - 良好的用户界面,可适应桌面/移动端/明暗主题! +- 功能丰富! + - 网页界面,所有人都可以用! + - 控制打印机配置 + - 打印照片,或单纯地进行测试 + - 命令行,技术爱好者必备! + - 使用一些参数控制打印机 + - 简易、简化的文字打印 + - 让程序的每一部分发挥作用 + - 其他一些好东西! + - 服务器也具有 CUPS/IPP 能力 + - 跨平台! - 较新的 Windows 10 及以上 - GNU/Linux @@ -76,11 +74,17 @@ python3 server.py sudo pacman -S bluez bluez-utils ``` +*以后将有软件包!* + ### MacOS -MacOS 用户请首先安装 [Python 3](https://www.python.org/)。 +MacOS 用户请首先安装 [Python 3](https://www.python.org/), +然后在终端使用 `pip` 安装 `pyobjc` 和 `bleak`: +```bash +pip3 install pyobjc bleak +``` -然后获取“纯净(pure)”版,并做同样的事情: +然后获取“单一(bare)”版,并做同样的事情: ```bash python3 server.py ``` @@ -94,15 +98,13 @@ python3 server.py 当已安装 [Python 3](https://www.python.org/) 时,您可以直接获取“纯净(pure)”版, 或在已使用 `pip` 安装 `bleak` 时使用“单一(bare)”版。 -命令行高手?直接用 `printer.py`! - 查看所有[发布版本](https://github.com/NaitLee/Cat-Printer/releases)! ## 有问题? -有想法?开个 issue! +有想法?去 Discussion 讨论! -如果能行,PR 也可以! +如果能行,Pull Request 也可以! ## 许可证 @@ -126,7 +128,7 @@ Copyright © 2022 NaitLee Soft. 保留一些权利。 - 当然不能没有 Python 和 Web 技术! - [Bleak](https://bleak.readthedocs.io/en/latest/) 跨平台蓝牙低功耗库,牛! -- [roddeh-i18n](https://github.com/roddeh/i18njs),很好! +- [roddeh-i18n](https://github.com/roddeh/i18njs),当前内置的国际化脚本受此启发 - [python-for-android](https://python-for-android.readthedocs.io/en/latest/),虽然有些麻烦的地方 - [AdvancedWebView](https://github.com/delight-im/Android-AdvancedWebView) 从 Java 拯救了我的生命 - Stack Overflow 和整个互联网,你们让我从零开始了解了安卓“活动” `Activity` diff --git a/server.py b/server.py index 8ae2113..28788ca 100644 --- a/server.py +++ b/server.py @@ -1,16 +1,25 @@ 'Cat Printer - Serve a Web UI' -# if pylint is annoying you, see file .pylint-rc +# if pylint is annoying you, see file .pylintrc import os +import io import sys import json -import asyncio import platform -# Don't use ThreadingHTTPServer if you're going to use pyjnius! -from http.server import BaseHTTPRequestHandler, HTTPServer #, ThreadingHTTPServer -from bleak.exc import BleakDBusError, BleakError -from printer import PrinterDriver +from http.server import BaseHTTPRequestHandler + +# For now we can't use `ThreadingHTTPServer` +from http.server import HTTPServer + +# import `printer` first, to diagnostic some common errors +from printer import PrinterDriver, PrinterError, I18n, info + +from bleak.exc import BleakDBusError, BleakError # pylint: disable=wrong-import-order + +from printer_lib.ipp import IPP + +IsAndroid = (os.environ.get("P4A_BOOTSTRAP") is not None) class DictAsObject(dict): """ Let you use a dict like an object in JavaScript. @@ -20,23 +29,8 @@ class DictAsObject(dict): def __setattr__(self, key, value): self[key] = value -class PrinterServerError(Exception): +class PrinterServerError(PrinterError): 'Error of PrinterServer' - code: int - name: str - details: str - def __init__(self, *args, code=1): - super().__init__(*args) - len_args = len(args) - self.code = code - if len_args > 0: - self.name = args[0] - if len_args > 1: - self.details = args[1] - -def log(message): - 'For logging a message' - print(message) mime_type = { 'html': 'text/html;charset=utf-8', @@ -51,24 +45,34 @@ def mime(url: str): 'Get pre-defined MIME type of a certain url by extension name' return mime_type.get(url.rsplit('.', 1)[-1], mime_type['octet-stream']) -class PrinterServer(BaseHTTPRequestHandler): - '(Local) server for Cat Printer Web interface' +class PrinterServerHandler(BaseHTTPRequestHandler): + '(Local) server handler for Cat Printer Web interface' + buffer = 4 * 1024 * 1024 + max_payload = buffer * 16 + settings = DictAsObject({ 'config_path': 'config.json', + 'version': 1, 'is_android': False, - 'printer': None, - 'scan_time': 3, - 'frequency': 0.8, + 'scan_timeout': 5.0, 'dry_run': False }) - printer = PrinterDriver() - ipp = None + _settings_blacklist = ( + 'printer', 'is_android' + ) + + printer: PrinterDriver = PrinterDriver() + + ipp: IPP = None + def log_request(self, _code=200, _size=0): pass + def log_error(self, *_args): pass + def do_GET(self): 'Called when server get a GET http request' path = 'www' + self.path @@ -91,24 +95,28 @@ class PrinterServer(BaseHTTPRequestHandler): if not self.wfile.write(chunk): break return - def api_success(self): - 'Called when a simple API call is being considered successful' + + def api_success(self, body_json=None): + 'Called when an API call is being considered successful' self.send_response(200) self.send_header('Content-Type', mime('json')) self.end_headers() - self.wfile.write(b'{}') - def api_fail(self, error_json, error=None): + if body_json is None: + self.wfile.write(b'{}') + else: + self.wfile.write(json.dumps(body_json).encode('utf-8')) + + def api_fail(self, error_json): 'Called when an API call is failed' self.send_response(500) self.send_header('Content-Type', mime('json')) self.end_headers() self.wfile.write(json.dumps(error_json).encode('utf-8')) self.wfile.flush() - if isinstance(error, Exception): - raise error + def load_config(self): 'Load config file, or if not exist, create one with default' - if os.environ.get("P4A_BOOTSTRAP") is not None: + if IsAndroid: self.settings['is_android'] = True from android.storage import app_storage_path # pylint: disable=import-error settings_path = app_storage_path() @@ -118,19 +126,38 @@ class PrinterServer(BaseHTTPRequestHandler): ) if os.path.exists(self.settings.config_path): with open(self.settings.config_path, 'r', encoding='utf-8') as file: - self.settings = DictAsObject(json.load(file)) + settings = DictAsObject(json.load(file)) + if (settings.version is None or + settings.version < self.settings.version): + # Version too old, start over + # TODO: selective? + self.save_config() + return + self.settings = settings else: self.save_config() + def save_config(self): 'Save config file' with open(self.settings.config_path, 'w', encoding='utf-8') as file: - json.dump(self.settings, file, indent=4) + settings = {} + for i in self.settings: + if i not in self._settings_blacklist: + settings[i] = self.settings[i] + json.dump(settings, file, indent=4) + def update_printer(self): 'Update `PrinterDriver` state/config' self.printer.dry_run = self.settings.dry_run - self.printer.frequency = float(self.settings.frequency) + self.printer.scan_timeout = self.settings.scan_timeout + self.printer.fake = self.settings.fake + self.printer.dump = self.settings.dump + self.printer.flip_h = self.settings.flip_h + self.printer.flip_v = self.settings.flip_v if self.settings.printer is not None: - self.printer.name, self.printer.address = self.settings.printer.split(',') + name, address = self.settings.printer.split(',') + self.printer.connect(name, address) + def handle_api(self): 'Handle API request from POST' content_length = int(self.headers.get('Content-Length')) @@ -138,121 +165,110 @@ class PrinterServer(BaseHTTPRequestHandler): api = self.path[1:] if api == 'print': self.update_printer() - loop = asyncio.new_event_loop() - try: - devices = loop.run_until_complete( - self.printer.print_data(body) - ) - self.api_success() - finally: - loop.close() + self.printer.print(io.BytesIO(body)) + self.api_success() return data = DictAsObject(json.loads(body)) if api == 'devices': - loop = asyncio.new_event_loop() - try: - devices = loop.run_until_complete( - self.printer.search_all_printers(float(self.settings.scan_time)) - ) - finally: - loop.close() + self.printer.connect(None) devices_list = [{ 'name': device.name, 'address': device.address - } for device in devices] - self.send_response(200) - self.send_header('Content-Type', mime('json')) - self.end_headers() - self.wfile.write(json.dumps({ + } for device in self.printer.scan()] + self.api_success({ 'devices': devices_list - }).encode('utf-8')) + }) return if api == 'query': self.load_config() - self.send_response(200) - self.send_header('Content-Type', mime('json')) - self.end_headers() - self.wfile.write(json.dumps(self.settings).encode('utf-8')) + self.api_success(self.settings) return if api == 'set': for key in data: self.settings[key] = data[key] self.save_config() + self.update_printer() self.api_success() return if api == 'exit': self.api_success() - self.save_config() - # Only usable when using ThreadingHTTPServer - # server.shutdown() - sys.exit(0) + self.exit() + + def exit(self): + 'Stop correctly & cleanly' + self.save_config() + self.printer.unload() + sys.exit(0) + def do_POST(self): 'Called when server get a POST http request' content_length = int(self.headers.get('Content-Length', -1)) - if (content_length == -1 or + if (content_length < -1 or content_length > self.max_payload ): - self.send_response(400) - self.send_header('Content-Type', mime('txt')) - self.end_headers() return if self.headers.get('Content-Type') == 'application/ipp': if self.ipp is None: - try: - from additional.ipp import IPP - self.load_config() - except ImportError: - # TODO: Better response? - return - self.ipp = IPP(self, self.printer) - self.update_printer() - body = self.rfile.read(content_length) - loop = asyncio.new_event_loop() - try: - loop.run_until_complete(self.ipp.handle_ipp(body)) - finally: - loop.close() + self.ipp = IPP(self) + self.ipp.handle_ipp() return try: self.handle_api() return except BleakDBusError as e: + # TODO: better error reporting self.api_fail({ - 'code': -2, 'name': e.dbus_error, 'details': e.dbus_error_details }) except BleakError as e: self.api_fail({ - 'code': -3, 'name': 'BleakError', 'details': str(e) }) - except PrinterServerError as e: + except PrinterError as e: self.api_fail({ - 'code': e.code, - 'name': e.name, - 'details': e.details + 'name': e.message, + 'details': e.message_localized }) except Exception as e: self.api_fail({ - 'code': -1, 'name': 'Exception', 'details': str(e) - }, e) + }) + raise + +class PrinterServer(HTTPServer): + ''' (local) server for Cat Printer Web Interface + The reason to override is to only init the handler once, + avoiding confliction, and stop cleanly + ''' + + handler: PrinterServerHandler = None + + def finish_request(self, request, client_address): + if self.handler is None: + self.handler = PrinterServerHandler(request, client_address, self) + return + self.handler.__init__(request, client_address, self) + + def server_close(self): + if self.handler is not None: + self.handler.exit() + super().server_close() + def serve(): 'Start server' address, port = '127.0.0.1', 8095 listen_all = False if '-a' in sys.argv: - print('Will listen on ALL addresses') + info(I18n['will-listen-on-all-addresses']) listen_all = True - # Again, Don't use ThreadingHTTPServer if you're going to use pyjnius! - server = HTTPServer(('' if listen_all else address, port), PrinterServer) + server = PrinterServer(('' if listen_all else address, port), PrinterServer) service_url = f'http://{address}:{port}/' if '-s' in sys.argv: - print(service_url) + info(I18n['serving-at-0', service_url]) else: operating_system = platform.uname().system if operating_system == 'Windows': @@ -262,11 +278,11 @@ def serve(): # TODO: I don't know about macOS # elif operating_system == 'macOS': else: - print(f'Will serve application at: {service_url}') + info(I18n['serving-at-0', service_url]) try: server.serve_forever() except KeyboardInterrupt: - pass + server.server_close() if __name__ == '__main__': serve() diff --git a/www/i18n-ext.js b/www/i18n-ext.js new file mode 100644 index 0000000..ec35797 --- /dev/null +++ b/www/i18n-ext.js @@ -0,0 +1,49 @@ + +/** + * Methods to know which string to use, per language + */ +var I18nExtensions = (function() { + + /** @type {ExtensionOf<'en-US'>} */ + function english(things, conditions) { + let text = conditions; + for (let index in things) { + let value = things[index]; + if (value == 1) text = conditions['single']; + else text = conditions['multiple']; + if (!text && typeof value === 'number') { + if (value < 10 || value > 20) { + if (value % 10 === 1) text = conditions['1st']; + else if (value % 10 === 2) text = conditions['2nd']; + else if (value % 10 === 3) text = conditions['3rd']; + else text = conditions['nth']; + } else text = conditions['nth']; + } + } + return text; + } + + /** @type {ExtensionOf<'zh-CN'>} */ + function chinese(things, conditions) { + let text = conditions; + return text; + } + + /** @type {ExtensionOf<'de-DE'>} */ + function german(things, conditions) { + let text = conditions; + for (let index in things) { + let value = things[index]; + if (value == 1) text = conditions['single']; + else text = conditions['multiple']; + } + return text; + } + + return { + 'en-US': english, + 'zh-CN': chinese, + 'de-DE': german + } + +})(); diff --git a/www/i18n.d.ts b/www/i18n.d.ts new file mode 100644 index 0000000..5db91b6 --- /dev/null +++ b/www/i18n.d.ts @@ -0,0 +1,32 @@ + +type DictOf = { [key: string]: T }; +type Conditions = DictOf; +type ConditionsOf = AllConditions[K]; +type LanguageData = DictOf; +type Things = { [index: number | string]: number | string } | Array; +type Extension = (things: Things, conditions: Conditions) => string; +type ExtensionOf = (things: Things, conditions: ConditionsOf) => string; +type Languages = keyof AllConditions; + +/** + * All known possible condition keys, per language + */ +type AllConditions = { + 'en-US': { + 'single': string, + 'multiple': string, + '1st': string, + '2nd': string, + '3rd': string, + 'nth': string + }, + 'de-DE': { + 'single': string, + 'multiple': string + }, + 'zh-CN': {} +}; + +interface I18nCallable extends I18n { + (text: string, things: Things, can_change_things: boolean): string; +} diff --git a/www/i18n.js b/www/i18n.js new file mode 100644 index 0000000..61b0264 --- /dev/null +++ b/www/i18n.js @@ -0,0 +1,109 @@ + +'use strict'; + +/** + * Yet another i18n solution + */ +class I18n { + /** @type {DictOf} */ + database; + /** @type {DictOf} */ + extensions; + /** @type {Languages} */ + language; + constructor(language = 'en') { + this.database = {}; + this.extensions = {}; + this.useLanguage(language); + } + /** + * Use this language as main language + * @param {Languages} language + */ + useLanguage(language) { + this.language = language; + if (!this.database[language]) + this.database[language] = {}; + } + /** + * Add data as corresponding language, also to + * other (added) languages as fallback + * @param {Languages} language + * @param {LanguageData} data + */ + add(language, data) { + if (!this.database[language]) + this.database[language] = {}; + for (let key in data) { + let value = data[key]; + this.database[language][key] = value; + for (let lang in this.database) + if (!this.database[lang][key]) + this.database[lang][key] = value; + } + } + /** + * Use extension in the language + * @param {Languages} language + * @param {Extension} extension + */ + extend(language, extension) { + this.extensions[language] = extension; + } + /** + * Alias a language code to another, usually formal/more used/as fallback + * @param {DictOf} aliases + */ + alias(aliases) { + for (let alt_code in aliases) { + let code = aliases[alt_code]; + this.database[alt_code] = this.database[code]; + this.extensions[alt_code] = this.extensions[code]; + } + } + /** + * Translate a string ("text"), using "things" such as numbers + * @param {string} text + * @param {Things} things + * @param {boolean} can_change_things + */ + translate(text, things, can_change_things = true) { + let conditions = this.database[this.language][text] || text; + if (!things) return conditions; + if (!can_change_things) things = { ... things }; + if (this.extensions[this.language] && typeof conditions !== 'string') + text = this.extensions[this.language](things, conditions); + else text = conditions; + for (let key in things) { + text = text.replace(`{${key}}`, things[key].toString()); + } + return text; + } +} + +/** + * A i18n instance that is directly callable + * @type {I18nCallable} + */ +var i18n = (function() { + + let instance = new I18n(); + + /** + * @param {string} text + * @param {Things} things + * @param {boolean} can_change_things + */ + let i18n_callable = function(text, things, can_change_things = true) { + return instance.translate.call(i18n_callable, text, things, can_change_things); + } + + Object.setPrototypeOf(i18n_callable, instance); + + if (typeof I18nExtensions === 'object') { + for (let key in I18nExtensions) + instance.extend(key, I18nExtensions[key]); + } + + return i18n_callable; +})(); diff --git a/www/index.html b/www/index.html index 56ca0e5..248ac8f 100644 --- a/www/index.html +++ b/www/index.html @@ -87,15 +87,15 @@ seconds
- - + + + +
+ +
System diff --git a/www/jslicense.html b/www/jslicense.html index f3c8eaa..d789a95 100644 --- a/www/jslicense.html +++ b/www/jslicense.html @@ -42,10 +42,16 @@ i18n.js - Expat - i18n.js + CC0-1.0-only + i18n.js For internationalization (language support) + + i18n-ext.js + CC0-1.0-only + i18n-ext.js + I18n "extensions" + main.js GNU-GPL-3.0-or-later diff --git a/www/lang/de-DE.json b/www/lang/de-DE.json index 009408d..357924c 100644 --- a/www/lang/de-DE.json +++ b/www/lang/de-DE.json @@ -1,65 +1,61 @@ { - "values": { - "cat-printer": "Cat Printer", - "printer": "Drucker", - "device-": "Gerät:", - "refresh": "Aktualisieren", - "mode-": "Modus:", - "canvas": "Leinwand", - "document": "Dokument", - "insert-picture": "Bild einfügen", - "help": "Hilfe", - "javascript-license-information": "Informationen zur JavaScript-Lizenz", - "settings": "Einstellungen", - "image": "Bild", - "monochrome-algorithm-": "Schwarzweiß-Algorithmus:", - "direct": "Direkt", - "floyd-steinberg": "Floyd Steinberg", - "halftone": "Halbtone", - "wave": "Wave", - "fall": "Fall", - "legacy": "Legacy", - "threshold-": "Schwellwert", - "transmission-speed-": "Übertragungsgeschwindigkeit:", - "low": "Gering", - "moderate": "Moderat", - "high": "Hoch", - "transparent-as-white": "Transparent als Weiß", - "misc": "Sonstiges", - "system": "System", - "disable-page-animation": "Seitenanimation ausschalten", - "exit": "Exit", - "error-message": "Fehlermeldung", - "preview": "Vorschau", - "print": "Drucken", - "expand": "Erweitern", - "crop": "Zuschneiden", - "scanning-for-devices": "Scannen nach Geräten…", - "scan-time-": "Scanzeit:", - "-seconds": "Sekunden", - "no-available-devices-found": "Keine verfügbaren Geräte gefunden", - "found-1-available-devices": [ - [1, 1, "1 verfügbares Gerät gefunden"], - [2, null, "%n verfügbare Geräte gefunden"] - ], - "please-check-if-the-printer-is-down": "Bitte prüfe, ob der Drucker ausgeschaltet ist", - "printing": "Drucken…", - "finished": "Fertiggestellt", - "coming-soon-": "Demnächst verfügbar…", - "dry-run": "Testlauf", - "dry-run-test-print-process-only": "Testlauf: nur Probedruckvorgang", - "you-can-close-this-page-manually": "Sie können diese Seite manuell schließen", - "please-enable-bluetooth": "Bitte aktivieren Sie Bluetooth", - "error-happened-please-check-error-message": "Fehler aufgetreten, bitte Fehlermeldung einsehen", - "you-can-seek-for-help-with-detailed-info-below": "Sie können mit den nachstehenden ausführlichen Informationen Hilfe bekommen.", - "or-try-to-scan-longer": "Oder versuchen Sie, länger zu suchen", - "print-pbm-image-to-cat-printer": "PBM-Bild auf Cat Printer drucken.", - "supported-models-": "Unterstützte Modelle:", - "path-to-pbm-file-dash-for-stdin": "Pfad zur PBM-Datei. '-' für stdin.", - "scan-for-specified-seconds": "Suchlauf für die angegebenen Sekunden", - "specify-printer-mac-address": "Geben Sie die MAC-Adresse des Druckers an", - "communication-frequency-0.8-or-1-recommended": "Übermittlungsfrequenz. 0,8 oder 1 empfohlen.", - "dump-the-traffic-to-printer-and-pbm-image-when-text-printing": "Den Datenverkehr auf dem Drucker ausgeben und PBM-Bild beim Textdruck.", - "text-printing-mode-input-text-from-stdin": "Textdruckmodus, Eingabe von stdin." - } + "cat-printer": "Cat Printer", + "printer": "Drucker", + "device-": "Gerät:", + "refresh": "Aktualisieren", + "mode-": "Modus:", + "canvas": "Leinwand", + "document": "Dokument", + "insert-picture": "Bild einfügen", + "help": "Hilfe", + "javascript-license-information": "Informationen zur JavaScript-Lizenz", + "settings": "Einstellungen", + "image": "Bild", + "monochrome-algorithm-": "Schwarzweiß-Algorithmus:", + "direct": "Direkt", + "floyd-steinberg": "Floyd Steinberg", + "halftone": "Halbtone", + "wave": "Wave", + "fall": "Fall", + "legacy": "Legacy", + "threshold-": "Schwellwert", + "transmission-speed-": "Übertragungsgeschwindigkeit:", + "low": "Gering", + "moderate": "Moderat", + "high": "Hoch", + "transparent-as-white": "Transparent als Weiß", + "misc": "Sonstiges", + "system": "System", + "disable-page-animation": "Seitenanimation ausschalten", + "exit": "Exit", + "error-message": "Fehlermeldung", + "preview": "Vorschau", + "print": "Drucken", + "expand": "Erweitern", + "crop": "Zuschneiden", + "scanning-for-devices": "Scannen nach Geräten…", + "scan-time-": "Scanzeit:", + "-seconds": "Sekunden", + "no-available-devices-found": "Keine verfügbaren Geräte gefunden", + "found-0-available-devices": { + "single": "{0} verfügbares Gerät gefunden", + "multiple": "{0} verfügbare Geräte gefunden" + }, + "please-check-if-the-printer-is-down": "Bitte prüfe, ob der Drucker ausgeschaltet ist", + "printing": "Drucken…", + "finished": "Fertiggestellt", + "coming-soon-": "Demnächst verfügbar…", + "dry-run": "Testlauf", + "dry-run-test-print-process-only": "Testlauf: nur Probedruckvorgang", + "you-can-close-this-page-manually": "Sie können diese Seite manuell schließen", + "please-enable-bluetooth": "Bitte aktivieren Sie Bluetooth", + "error-happened-please-check-error-message": "Fehler aufgetreten, bitte Fehlermeldung einsehen", + "you-can-seek-for-help-with-detailed-info-below": "Sie können mit den nachstehenden ausführlichen Informationen Hilfe bekommen", + "or-try-to-scan-longer": "Oder versuchen Sie, länger zu suchen", + "print-to-cat-printer": "PBM-Bild auf Cat Printer drucken", + "supported-models-": "Unterstützte Modelle:", + "path-to-input-file-dash-for-stdin": "Pfad zur Datei. '-' für stdin", + "scan-for-specified-seconds": "Suchlauf für die angegebenen Sekunden", + "dump-the-traffic": "Den Datenverkehr auf dem Drucker ausgeben und PBM-Bild beim Textdruck", + "text-printing-mode": "Textdruckmodus" } \ No newline at end of file diff --git a/www/lang/en-US.json b/www/lang/en-US.json index 166f517..ecc0828 100644 --- a/www/lang/en-US.json +++ b/www/lang/en-US.json @@ -1,67 +1,87 @@ { - "values": { - "cat-printer": "Cat Printer", - "printer": "Printer", - "device-": "Device:", - "refresh": "Refresh", - "mode-": "Mode:", - "canvas": "Canvas", - "document": "Document", - "insert-picture": "Insert Picture", - "help": "Help", - "javascript-license-information": "JavaScript License Information", - "settings": "Settings", - "image": "Image", - "monochrome-algorithm-": "Monochrome Algorithm:", - "direct": "Direct", - "floyd-steinberg": "Floyd Steinberg", - "halftone": "Halftone", - "wave": "Wave", - "fall": "Fall", - "legacy": "Legacy", - "threshold-": "Threshold", - "transmission-speed-": "Transmission Speed:", - "low": "Low", - "moderate": "Moderate", - "high": "High", - "transparent-as-white": "Transparent as White", - "misc": "Misc", - "system": "System", - "disable-page-animation": "Disable Page Animation", - "exit": "Exit", - "error-message": "Error Message", - "preview": "Preview", - "print": "Print", - "expand": "Expand", - "crop": "Crop", - "scanning-for-devices": "Scanning for devices…", - "scan-time-": "Scan time:", - "-seconds": "seconds", - "no-available-devices-found": "No available devices found", - "found-1-available-devices": [ - [1, 1, "Found 1 available device"], - [2, null, "Found %n available devices"] - ], - "please-check-if-the-printer-is-down": "Please check if the printer is down", - "printing": "Printing…", - "finished": "Finished", - "coming-soon-": "Coming Soon…", - "dry-run": "Dry Run", - "dry-run-test-print-process-only": "Dry Run: test print process only", - "you-can-close-this-page-manually": "You can close this page manually", - "please-enable-bluetooth": "Please enable Bluetooth", - "error-happened-please-check-error-message": "Error happened, please check error message", - "you-can-seek-for-help-with-detailed-info-below": "You can seek for help with detailed info below.", + "cat-printer": "Cat Printer", + "printer": "Printer", + "device-": "Device:", + "refresh": "Refresh", + "mode-": "Mode:", + "canvas": "Canvas", + "document": "Document", + "insert-picture": "Insert Picture", + "help": "Help", + "javascript-license-information": "JavaScript License Information", + "settings": "Settings", + "image": "Image", + "monochrome-algorithm-": "Monochrome Algorithm:", + "direct": "Direct", + "floyd-steinberg": "Floyd Steinberg", + "halftone": "Halftone", + "wave": "Wave", + "fall": "Fall", + "legacy": "Legacy", + "threshold-": "Threshold", + "transmission-speed-": "Transmission Speed:", + "low": "Low", + "moderate": "Moderate", + "high": "High", + "transparent-as-white": "Transparent as White", + "misc": "Misc", + "system": "System", + "disable-page-animation": "Disable Page Animation", + "exit": "Exit", + "error-message": "Error Message", + "preview": "Preview", + "print": "Print", + "expand": "Expand", + "crop": "Crop", + "scanning-for-devices": "Scanning for devices…", + "scan-time-": "Scan time:", + "-seconds": "seconds", + "no-available-devices-found": "No available devices found", + "found-0-available-devices": { + "single": "Found {0} available device", + "multiple": "Found {0} available devices" + }, + "please-check-if-the-printer-is-down": "Please check if the printer is down", + "printing": "Printing…", + "finished": "Finished", + "coming-soon-": "Coming Soon…", + "dry-run": "Dry Run", + "dry-run-test-print-process-only": "Dry Run: test print process only", + "you-can-close-this-page-manually": "You can close this page manually", + "please-enable-bluetooth": "Please enable Bluetooth", + "error-happened-please-check-error-message": "Error happened, please check error message", + "you-can-seek-for-help-with-detailed-info-below": "You can seek for help with detailed info below", - "or-try-to-scan-longer": "Or try to scan longer", - "print-pbm-image-to-cat-printer": "Print PBM image to Cat Printer.", - "supported-models-": "Supported models:", - "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.", + "or-try-to-scan-longer": "Or try to scan longer", + "print-to-cat-printer": "Print to Cat Printer", + "supported-models-": "Supported models:", + "path-to-input-file-dash-for-stdin": "Path to input file. '-' for stdin", + "scan-for-specified-seconds": "Scan for specified seconds", - "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." - } + "dump-the-traffic": "Dump the traffic", + "text-printing-mode": "Text printing mode", + + "please-install-pyobjc-via-pip": "Please install `pyobjc` via pip", + "please-install-bleak-via-pip": "Please install `bleak` via pip", + "folder-printer_lib-is-incomplete-or-missing-please-check": "Folder `printer_lib` is incomplete or missing, please check", + "input-is-not-pbm-image": "Input is not PBM image", + "unsuitable-image-width-expected-0-got-1": "Unsuitable image width, expected {0}, got {1}", + "broken-pbm-image": "Broken PBM image", + "input-is-not-text-file": "Input is not text file", + "match-printer-with-this-name-or-address": "Match printer with this name or address", + "virtual-run-on-specified-model": "Virtual run on specified model", + "font-size-0": "Font size {0}", + "stopping": "Stopping", + "connecting": "Connecting", + "model-0-is-not-supported-yet": "Model '{0}' is not supported yet", + "invalid-address-0": "Invalid address: '{0}'", + "will-listen-on-all-addresses": "Will listen on ALL addresses", + "serving-at-0": "Serving at {0}", + "disconnecting-from-printer": "Disconnecting from printer", + "connected-to-0-1": "Connected to {0} {1}", + "flip-horizontally": "Flip Horizontally", + "flip-vertically": "Flip Vertically", + "dump-traffic": "Dump Traffic", + "right-to-left-text-order": "Right-to-left text order", + "auto-wrap-line": "Auto wrap line" } \ No newline at end of file diff --git a/www/lang/zh-CN.json b/www/lang/zh-CN.json index a079ee1..45f983e 100644 --- a/www/lang/zh-CN.json +++ b/www/lang/zh-CN.json @@ -1,66 +1,83 @@ { - "values": { - "cat-printer": "猫咪打印机", - "printer": "打印机", - "device-": "设备:", - "refresh": "刷新", - "mode-": "模式:", - "canvas": "画布", - "document": "文档", - "insert-picture": "插入图片", - "help": "帮助", - "javascript-license-information": "JavaScript 许可证信息", - "settings": "设置", - "monochrome-algorithm-": "单色化算法:", - "direct": "直接", - "image": "图像", - "floyd-steinberg": "科学", - "halftone": "点状", - "wave": "波纹", - "fall": "下落", - "legacy": "旧版", - "threshold-": "阈值:", - "transmission-speed-": "传输速度:", - "low": "低", - "moderate": "适中", - "high": "高", - "transparent-as-white": "透明为白色", - "misc": "杂项", - "system": "系统", - "disable-page-animation": "禁用页面动画", - "exit": "退出", - "error-message": "错误消息", - "preview": "预览", - "print": "打印", - "expand": "扩大", - "crop": "裁减", - "scanning-for-devices": "正在扫描设备……", - "scan-time-": "扫描时间:", - "-seconds": "秒", - "no-available-devices-found": "未发现可用设备", - "found-1-available-devices": [ - [1, null, "发现 %n 个可用设备"] - ], - "please-check-if-the-printer-is-down": "请检查打印机是否已关闭", - "printing": "打印中……", - "finished": "完成", - "coming-soon-": "即将到来……", - "dry-run": "干运行", - "dry-run-test-print-process-only": "干运行:仅测试打印流程", - "you-can-close-this-page-manually": "您可手动关闭此页面", - "please-enable-bluetooth": "请启用蓝牙", - "error-happened-please-check-error-message": "发生错误,请检查错误消息", - "you-can-seek-for-help-with-detailed-info-below": "您可以使用以下详细信息寻求帮助。", + "cat-printer": "猫咪打印机", + "printer": "打印机", + "device-": "设备:", + "refresh": "刷新", + "mode-": "模式:", + "canvas": "画布", + "document": "文档", + "insert-picture": "插入图片", + "help": "帮助", + "javascript-license-information": "JavaScript 许可证信息", + "settings": "设置", + "monochrome-algorithm-": "单色化算法:", + "direct": "直接", + "image": "图像", + "floyd-steinberg": "科学", + "halftone": "点状", + "wave": "波纹", + "fall": "下落", + "legacy": "旧版", + "threshold-": "阈值:", + "transmission-speed-": "传输速度:", + "low": "低", + "moderate": "适中", + "high": "高", + "transparent-as-white": "透明为白色", + "misc": "杂项", + "system": "系统", + "disable-page-animation": "禁用页面动画", + "exit": "退出", + "error-message": "错误消息", + "preview": "预览", + "print": "打印", + "expand": "扩大", + "crop": "裁减", + "scanning-for-devices": "正在扫描设备……", + "scan-time-": "扫描时间:", + "-seconds": "秒", + "no-available-devices-found": "未发现可用设备", + "found-0-available-devices": "发现 {0} 个可用设备", + "please-check-if-the-printer-is-down": "请检查打印机是否已关闭", + "printing": "打印中……", + "finished": "完成", + "coming-soon-": "即将到来……", + "dry-run": "干运行", + "dry-run-test-print-process-only": "干运行:仅测试打印流程", + "you-can-close-this-page-manually": "您可手动关闭此页面", + "please-enable-bluetooth": "请启用蓝牙", + "error-happened-please-check-error-message": "发生错误,请检查错误消息", + "you-can-seek-for-help-with-detailed-info-below": "您可以使用以下详细信息寻求帮助", - "or-try-to-scan-longer": "或者尝试延长扫描时间", - "print-pbm-image-to-cat-printer": "打印 PBM 图片到猫咪打印机。", - "supported-models-": "支持的型号:", - "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。", + "or-try-to-scan-longer": "或者尝试延长扫描时间", + "print-to-cat-printer": "打印到猫咪打印机。", + "supported-models-": "支持的型号:", + "path-to-input-file-dash-for-stdin": "输入文件的位置。使用 '-' 作为标准输入", + "scan-for-specified-seconds": "扫描指定的时长", - "dump-the-traffic-to-printer-and-pbm-image-when-text-printing": "转储到打印机的数据,和文字打印模式的 PBM 图像。", - "text-printing-mode-input-text-from-stdin": "文字打印模式,将从标准输入读取文字。" - } + "dump-the-traffic": "转储数据", + "text-printing-mode": "文字打印模式", + + "please-install-pyobjc-via-pip": "请从 pip 安装 `pyobjc`", + "please-install-bleak-via-pip": "请从 pip 安装 `bleak`", + "folder-printer_lib-is-incomplete-or-missing-please-check": "文件夹 `printer_lib` 不完整或丢失,请检查", + "input-is-not-pbm-image": "输入不是 PBM 图像", + "unsuitable-image-width-expected-0-got-1": "不适合的图像宽度,需要 {0}, 输入为 {1}", + "broken-pbm-image": "损坏的 PBM 图像", + "input-is-not-text-file": "输入不是文本文件", + "match-printer-with-this-name-or-address": "使用符合此名称或地址的打印机", + "virtual-run-on-specified-model": "在指定的型号模拟运行", + "font-size-0": "字体大小 {0}", + "stopping": "停止中", + "connecting": "正在连接", + "model-0-is-not-supported-yet": "型号 '{0}' 仍未支持", + "invalid-address-0": "无效的地址:'{0}'", + "will-listen-on-all-addresses": "将接受所有地址的连接", + "serving-at-0": "服务器在 {0}", + "disconnecting-from-printer": "正在从打印机断开连接", + "flip-horizontally": "水平翻转", + "flip-vertically": "垂直翻转", + "dump-traffic": "转储数据", + "right-to-left-text-order": "从右到左的文字顺序", + "auto-wrap-line": "自动折行" } \ No newline at end of file diff --git a/www/loader.js b/www/loader.js index 5be8fc9..a37204e 100644 --- a/www/loader.js +++ b/www/loader.js @@ -6,7 +6,7 @@ var fallbacks = [ // main scripts, which we will directly modify - 'i18n.js', 'image.js', 'main.js', + 'i18n-ext.js', 'i18n.js', 'image.js', 'main.js', // "compatibility" script, produced with eg. typescript tsc 'main.comp.js' ]; diff --git a/www/main.js b/www/main.js index 3a5fe8f..5e03596 100644 --- a/www/main.js +++ b/www/main.js @@ -38,13 +38,13 @@ class _Notice { constructor() { this.element = document.getElementById('notice'); } - _message(message, ...args) { - this.element.innerText = i18n(message, ...args) || message; + _message(message, things) { + this.element.innerText = i18n(message, things) || message; } makeLogger(class_name) { - return (message, ...args) => { + return (message, things) => { this.element.classList.value = class_name; - this._message(message, ...args); + this._message(message, things); } } notice = this.makeLogger('notice'); @@ -373,7 +373,7 @@ class Main { putEvent('#button-exit', 'click', this.exit, this); putEvent('#button-print', 'click', this.print, this); putEvent('#device-refresh', 'click', this.searchDevices, this); - this.attachSetter('#scan-time', 'change', 'scan_time'); + this.attachSetter('#scan-time', 'change', 'scan_timeout'); this.attachSetter('#device-options', 'input', 'printer'); this.attachSetter('input[name="algo"]', 'change', 'mono_algorithm'); this.attachSetter('#transparent-as-white', 'change', 'transparent_as_white'); @@ -387,7 +387,9 @@ class Main { this.attachSetter('#threshold', 'change', 'threshold', (value) => this.canvasController.threshold = value ); - this.attachSetter('#frequency', 'change', 'frequency'); + this.attachSetter('#flip-h', 'change', 'flip_h'); + this.attachSetter('#flip-v', 'change', 'flip_v'); + this.attachSetter('#dump', 'change', 'dump'); await this.loadConfig(); this.searchDevices(); resolve(); @@ -488,7 +490,7 @@ class Main { hint('#device-refresh'); return; } - Notice.notice('found-1-available-devices', devices.length); + Notice.notice('found-0-available-devices', [devices.length]); hint('#insert-picture'); devices.forEach(device => { let option = document.createElement('option'); @@ -519,18 +521,15 @@ class Main { } async initI18n() { if (typeof i18n === 'undefined') return; - let language_list = navigator.languages; - let loaded_languages = []; - let data; - for (let i = language_list.length - 1; i >= 0; i--) { - data = await fetch(`/lang/${language_list[i]}.json`) + i18n.useLanguage(navigator.languages[0]); + for (let language of navigator.languages) { + let data = await fetch(`/lang/${language}.json`) .then(response => response.ok ? response.json() : null); if (data !== null) { - i18n.translator.add(data); - loaded_languages.unshift(language_list[i]); + i18n.add(language, data); + console.log('Loaded language:', language); } } - console.log('Language stack:', loaded_languages); let elements = document.querySelectorAll('*[data-i18n]'); let i18n_data, translated_string; elements.forEach(element => {