From 1de73c79d9559dd0a6cffd09980606d58d8b9c14 Mon Sep 17 00:00:00 2001 From: NaitLee Date: Wed, 6 Apr 2022 18:13:19 +0800 Subject: [PATCH] A new start! --- .gitignore | 166 ++----- .pylintrc | 30 ++ .vscode/launch.json | 17 + 0-transpile.sh | 4 + COPYING | 6 + README.md | 118 ++--- README.zh-CN.md | 85 ---- TODO | 9 + build-android/0-build-android.sh | 7 + build-android/1-adb-install.sh | 2 + build-android/2-clean-up-build.sh | 2 + build-android/3-formal-build.sh | 10 + build-android/8-build-push.sh | 3 + build-android/9-adb-logcat.sh | 2 + build-android/blacklist.txt | 111 +++++ build-android/blank.png | Bin 0 -> 544 bytes build-android/icon.png | Bin 0 -> 10050 bytes build-android/icon.svg | 98 +++++ build-common/0-bundle-all.sh | 4 + build-common/bundle.py | 122 ++++++ build-common/start.bat | 5 + dev-diary.txt | 57 +++ development.md | 115 +++++ main.py | 5 + printer.py | 298 +++++++++---- server.py | 427 +++++++++--------- www/_load.html | 28 ++ www/custom-print.html | 91 ---- www/custom-print.js | 141 ------ www/help/help.en-US.html | 33 -- www/help/help.zh-CN.html | 33 -- www/help/index.html | 18 - www/icon.png | Bin 0 -> 4212 bytes www/image.js | 211 +++++++++ www/index.html | 143 ++++++- www/jslicense.html | 92 ++++ www/lang/en-US.json | 56 +++ www/lang/zh-CN.json | 55 +++ www/loader.js | 39 ++ www/main.css | 311 ++++++++++++-- www/main.d.ts | 34 -- www/main.js | 688 +++++++++++++++++++++++------- www/polyfill.js | 8 + www/print-document.html | 57 --- www/print-document.js | 57 --- www/print-image.html | 45 -- www/print-image.js | 60 --- 47 files changed, 2567 insertions(+), 1336 deletions(-) create mode 100644 .pylintrc create mode 100644 .vscode/launch.json create mode 100755 0-transpile.sh create mode 100644 COPYING delete mode 100644 README.zh-CN.md create mode 100644 TODO create mode 100755 build-android/0-build-android.sh create mode 100755 build-android/1-adb-install.sh create mode 100755 build-android/2-clean-up-build.sh create mode 100755 build-android/3-formal-build.sh create mode 100755 build-android/8-build-push.sh create mode 100755 build-android/9-adb-logcat.sh create mode 100644 build-android/blacklist.txt create mode 100644 build-android/blank.png create mode 100644 build-android/icon.png create mode 100644 build-android/icon.svg create mode 100755 build-common/0-bundle-all.sh create mode 100644 build-common/bundle.py create mode 100644 build-common/start.bat create mode 100644 dev-diary.txt create mode 100644 development.md create mode 100644 main.py create mode 100644 www/_load.html delete mode 100644 www/custom-print.html delete mode 100644 www/custom-print.js delete mode 100644 www/help/help.en-US.html delete mode 100644 www/help/help.zh-CN.html delete mode 100644 www/help/index.html create mode 100644 www/icon.png create mode 100644 www/image.js create mode 100644 www/jslicense.html create mode 100644 www/lang/en-US.json create mode 100644 www/lang/zh-CN.json create mode 100644 www/loader.js delete mode 100644 www/main.d.ts create mode 100644 www/polyfill.js delete mode 100644 www/print-document.html delete mode 100644 www/print-document.js delete mode 100644 www/print-image.html delete mode 100644 www/print-image.js diff --git a/.gitignore b/.gitignore index 4e23deb..96bd117 100644 --- a/.gitignore +++ b/.gitignore @@ -1,140 +1,28 @@ - -bleak -www/skin.css -www/fabric.js -www/fabric.min.js -www/html2canvas.js -www/html2canvas.min.js -www/qrcode.js -www/qrcode.min.js +# python cache __pycache__ - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ +# 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 +build-android/advancedwebview +# python bytecode +*.pyc +# releases +build-android/dist +*.apk +cat-printer*.zip +# bleak, the bare pip package as a folder +build-common/bleak +# python embeddable package, with bleak_winrt inside +build-common/python-win32* +# dev config +config.json +# some other junk +.directory +thumbs.db +thumbs.db:encryptable diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..a93dee8 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,30 @@ + +# Apply this pylint-rc for better experience +# Configurable in VSCode settings `python.linting.pylintArgs` +# $ pylint --rcfile=.pylint-rc + +[MASTER] +jobs=4 + +[BASIC] +class-const-naming-style=snake_case +const-naming-style=snake_case + +[MESSAGES CONTROL] +disable=broad-except, + global-statement, + fixme, + import-outside-toplevel + +[BASIC] +good-names=i, + j, + k, + ex, + Run, + _, + e, + do_GET, + do_POST, + do_HEAD, + do_PUT diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4b8d0b0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Web Interface", + "type": "python", + "request": "launch", + "program": "server.py", + "args": ["-a", "-s"], + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/0-transpile.sh b/0-transpile.sh new file mode 100755 index 0000000..1229ef1 --- /dev/null +++ b/0-transpile.sh @@ -0,0 +1,4 @@ +#!/bin/sh +cd www +npx tsc --allowJs --outFile main.comp.js polyfill.js i18n.js image.js main.js +cd .. diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..58e1459 --- /dev/null +++ b/COPYING @@ -0,0 +1,6 @@ + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +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 . diff --git a/README.md b/README.md index 2f517b1..42a8222 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,107 @@ -English | [简体中文](README.zh-CN.md) # Cat-Printer -*A friendly cat (kitty) printer App/driver for everyone (GB01,GB02,GT01)* +A project that provides support to some Bluetooth "Cat Printer" models, on *many* platforms! -![Poster](https://repository-images.githubusercontent.com/403563361/0a315f6a-7cae-48d7-bfd4-d6fac5415d7c) +## Models -(According to [official website](http://office.frogtosea.com/jjfa), maybe there are also normal-, piggy- and frog-shaped printers with these models) +Currently: + GB01, GB02, and GT01 ## Features -- Print jpg/png images directly to cat printer from a web interface -- Print a document (.doc, .docx, .odt etc) by copy-paste -- Custom print content, put text, image, QRcode on a canvas -- (more will be here...) +- Simple! + - Operate via a Web UI just in browser, + - or get the Android release! +- ~~Feature-rich~~ + - Currently it is in Alpha stage. More will be there soon! + - You can still use the legacy version (0.0.2), with some more editing features +- Friendly! + - Language support! You can participate in translation! + - Good user interface, with PC/mobile/light/dark mode variants! (system config adaptive) +- Cross platform! + - Newer Windows 10 and above + - GNU/Linux + - MacOS *(Needs testing)* + - and a lot of extra efforts for Android! +- Free, as in [freedom](https://www.gnu.org/philosophy/free-sw.html)! + - Unlike the "official" proprietary app, + this project is for everyone that concerns *open-mind and freedom*! +- and Fun! + - Do whatever you like! -## How to use +## Get Started -On Windows 10: +### Android -- Get a release, extract, open `start.bat`. -- Make sure bluetooth of your computer is opened and cat printer is launched. +Get the newest apk release and install, then well done! -On GNU/Linux: +It may ask for background location permission, which is mysterious to me. +You can deny it safely. -- You can also use a Windows release, or prepare dependencies according to developer note. -- Open `server.py` in `printer` folder with `python3`. +### Windows: -Notes: +Get the newest release archive with "windows" in the file name, +extract to somewhere and run `start.bat` -- Newest Firefox users need to manually allow the permission of extracting canvas data, at left side of address bar after clicking preview button -- Windows version needs to be at least 10 (`10.0.16299`) -- GNU/Linux needs BlueZ (`bluetoothctl`) -- Maybe also compatible to Mac (Darwin) with CoreBluetooth Framework +### GNU/Linux -## Why? +You can get the "pure" release, extract it, fire a terminal inside and run: +```bash +python3 server.py +``` -These bluetooth cat printers, with model name GB01, GB02 and GT01, have poor support at applications. +On Arch Linux based distros you may first install `bluez`, as it's often missing +```bash +sudo pacman -S bluez bluez-utils +``` -Official apps are, proprietary, also have only mobile version. +### MacOS -I hate both proprietary and platform-binding things. So I decided to make this. +For MacOS please install [Python 3](https://www.python.org/). -Thankfully, people here are really warm-hearted, logged their experiences online in a [central repo](https://github.com/JJJollyjim/catprinter), and I am able to walk further 😃 +Fetch a "pure" release and do the same in a shell: +```bash +python3 server.py +``` -## Trivial +Currently in Mac the browser will not pop up automatically. Please run manually and go to `http://127.0.0.1:8095` -- Many one choose these cat thermal printers because they are cute... or, just cheap 🙃 -- Here we tell "**Cat Printer**" because other developers also call the printer as this, but what oversea shops call is "**Kitty Printer**". Search engines, please optimize it 😝 +### Note -- The official app is protected by law & copyright. I don't know if my work is not good... +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`. -## Developer Note +See the [releases](./releases) now! (`0.0.*` versions are legacy/deprecated) -This application uses server/client model, and have fewest possible dependencies on server side. +## Problems? -### Prepare +Please open an issue if there's something in your mind! -- Python3 & Browser -- [fabric.min.js](https://github.com/fabricjs/fabric.js/tree/master/dist) -- [html2canvas.min.js](https://html2canvas.hertzen.com/) -- [qrcode.min.js](https://davidshimjs.github.io/qrcodejs/) -- (Optional) Any css for plain webpage, e.g. [minicss](https://minicss.org/), rename to `skin.css` +Of course PRs are welcome if you can handle them! -Put any web-related files to folder `www`. +## License -### Supported Platforms +Copyright © 2022 NaitLee Soft. Some rights reserved. -Support for both Windows and GNU/Linux are included. And Windows release package will contain all needed things for a **normal** user to play with. +See file `COPYING`, `LICENSE`, and detail of used JavaScript in file `www/jslicense.html` -### Plans +-------- -- Smoother mono-color converting -- Make remote-print by web interface more standard/compatible/secure +## Development -Possible features: +You may interested in language support, anyway. See the translation files in directory `www/lang`! -- Remote print with printer protocols +Also interested in code development? See [development.md](development.md)! -### Files +### Credits -- `server.py`: Contains a BaseHTTP server that hooks user actions and printer driver -- `printer.py`: Contains the driver of bluetooth cat printer, which depends on bleak. You can also run this file in commandline. +- 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! +- [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 empty +- ... Everyone is Awesome! diff --git a/README.zh-CN.md b/README.zh-CN.md deleted file mode 100644 index ac4d368..0000000 --- a/README.zh-CN.md +++ /dev/null @@ -1,85 +0,0 @@ -[English](README.md) | 简体中文 - -# 猫咪打印机 Cat-Printer - -*一个友好的猫咪打印机 App/驱动,为用户而生 (GB01,GB02,GT01)* - -![Poster](https://repository-images.githubusercontent.com/403563361/0a315f6a-7cae-48d7-bfd4-d6fac5415d7c) - -(根据[官网](http://office.frogtosea.com/jjfa),可能也有普通/猪猪/青蛙外观的打印机拥有此种型号) - -## 功能 - -- 直接从网页界面打印 jpg/png 图像到猫咪打印机 -- 复制粘贴文档内容(.doc, .docx, .odt 等)以打印 -- 自定义打印内容,在画布上放置文字、图片、二维码 -- (会有更多……) - -## 如何使用 - -在 Windows 10 上: - -- 获取一份 release,解压,打开 `start.bat` -- 确保电脑蓝牙开启且猫咪打印机已启动。 - -在 GNU/Linux: - -- 您也可以使用 Windows release,或者依开发者注记准备依赖。 -- 使用 `python3` 打开位于 `printer` 文件夹的 `server.py`。 - -备注: - -- 最新的 Firefox 用户需要手动允许提取画布信息的权限(点击预览后,在地址栏左方) -- Windows 版本至少为 10 (`10.0.16299`) -- GNU/Linux 需要 BlueZ (`bluetoothctl`) -- 可能也兼容有 CoreBluetooth 框架的 Mac (Darwin) - -## 为什么? - -这些蓝牙猫咪打印机,型号为 GB01, GB02 和 GT01,没有足够的应用支持。 - -官方应用是专有的,且只有手机版本。 - -我讨厌专有软件和平台绑架。所以我做了这个。 - -幸运的是,这里的热心肠网友将他们的经验记录到了一个[中心仓库](https://github.com/JJJollyjim/catprinter),因此我可以走得更远 😃 - -## 花絮 - -- 很多人选择此种热敏打印机,因为它可爱……或者,只是便宜 🙃 - -- 其他开发者叫它“猫打印机”("Cat Printer"),但国外商店叫它“猫咪打印机”("Kitty Printer")。在英文文档有差别,提出以优化搜索引擎结果 😝 - -- 官方 App 受法律、版权、专利保护。不知道此 repo 是否合理…… - -## 开发者注记 - -此 App 使用服务器/客户端模型,且拥有尽可能少的服务端依赖。 - -### 准备 - -- Python3 与浏览器 -- [fabric.min.js](https://github.com/fabricjs/fabric.js/tree/master/dist) -- [html2canvas.min.js](https://html2canvas.hertzen.com/) -- [qrcode.min.js](https://davidshimjs.github.io/qrcodejs/) -- (可选)任何纯网页可用的 css,如 [minicss](https://minicss.org/),重命名为 `skin.css` - -将 web 相关的文件放在 `www` 文件夹中。 - -### 支持的平台 - -它同时包含对 Windows 和 GNU/Linux 的支持。Windows 发行包将包含一个**普通**用户所需要的所有。 - -### 计划 - -- 更好的双色转换 -- 使 web 界面的远程打印标准化/兼容/安全 - -可能的功能: - -- 使用打印协议的远程打印 - -## 文件 - -- `server.py`: 包含一个 BaseHTTP 服务器,关联用户操作与打印机驱动 -- `printer.py`: 包含蓝牙猫咪打印机的驱动,依赖 bleak。您也可以在命令行中运行此文件。 diff --git a/TODO b/TODO new file mode 100644 index 0000000..e036e72 --- /dev/null +++ b/TODO @@ -0,0 +1,9 @@ + ++ 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 ++ Write good help/manual ++ Make error notice short while let users see detailed help/manual for what-to-do ++ ... diff --git a/build-android/0-build-android.sh b/build-android/0-build-android.sh new file mode 100755 index 0000000..ba2c354 --- /dev/null +++ b/build-android/0-build-android.sh @@ -0,0 +1,7 @@ +#!/bin/sh +p4a apk --private .. --dist_name="cat-printer" --package="io.github.naitlee.catprinter" --name="Cat Printer" \ + --icon=icon.png --version="0.1.0" --bootstrap=webview --window --requirements=android,pyjnius,bleak \ + --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 \ + --permission=BLUETOOTH_ADMIN --permission=ACCESS_FINE_LOCATION --permission=ACCESS_COARSE_LOCATION diff --git a/build-android/1-adb-install.sh b/build-android/1-adb-install.sh new file mode 100755 index 0000000..adbdb8d --- /dev/null +++ b/build-android/1-adb-install.sh @@ -0,0 +1,2 @@ +#!/bin/sh +adb install cat-printer*.apk diff --git a/build-android/2-clean-up-build.sh b/build-android/2-clean-up-build.sh new file mode 100755 index 0000000..83f7672 --- /dev/null +++ b/build-android/2-clean-up-build.sh @@ -0,0 +1,2 @@ +#!/bin/sh +p4a clean_builds && p4a clean_dists diff --git a/build-android/3-formal-build.sh b/build-android/3-formal-build.sh new file mode 100755 index 0000000..e139001 --- /dev/null +++ b/build-android/3-formal-build.sh @@ -0,0 +1,10 @@ +#!/bin/sh +rm -rf "dist" +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 \ + --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-android/8-build-push.sh b/build-android/8-build-push.sh new file mode 100755 index 0000000..6342e04 --- /dev/null +++ b/build-android/8-build-push.sh @@ -0,0 +1,3 @@ +#!/bin/sh +./0-build-android.sh +./1-adb-install.sh diff --git a/build-android/9-adb-logcat.sh b/build-android/9-adb-logcat.sh new file mode 100755 index 0000000..6fcd2c7 --- /dev/null +++ b/build-android/9-adb-logcat.sh @@ -0,0 +1,2 @@ +#!/bin/sh +adb logcat | grep -E 'python|chromium' diff --git a/build-android/blacklist.txt b/build-android/blacklist.txt new file mode 100644 index 0000000..dbb68f9 --- /dev/null +++ b/build-android/blacklist.txt @@ -0,0 +1,111 @@ + +# dev +.vscode +.git +.gitignore +.pylintrc +?-*.sh + +# junk +__pycache__ +.directory +thumbs.db +thumbs.db:encryptable + +# other dist +cat-printer-windows-*.zip +cat-printer-pure-*.zip +cat-printer-bare-*.zip +build*/* + +# prevent user to include invalid extensions +*.apk +*.aab +*.apks +*.pxd + +# eggs +*.egg-info + +# unit test +unittest/* + +# python config +config/makesetup + +# unused kivy files (platform specific) +kivy/input/providers/wm_* +kivy/input/providers/mactouch* +kivy/input/providers/probesysfs* +kivy/input/providers/mtdev* +kivy/input/providers/hidinput* +kivy/core/camera/camera_videocapture* +kivy/core/spelling/*osx* +kivy/core/video/video_pyglet* +kivy/tools +kivy/tests/* +kivy/*/*.h +kivy/*/*.pxi + +# unused encodings +lib-dynload/*codec* +encodings/cp*.pyo +encodings/tis* +encodings/shift* +encodings/bz2* +encodings/iso* +encodings/undefined* +encodings/johab* +encodings/p* +encodings/m* +encodings/euc* +encodings/k* +encodings/unicode_internal* +encodings/quo* +encodings/gb* +encodings/big5* +encodings/hp* +encodings/hz* + +# unused python modules +bsddb/* +wsgiref/* +hotshot/* +pydoc_data/* +tty.pyo +anydbm.pyo +nturl2path.pyo +LICENCE.txt +macurl2path.pyo +dummy_threading.pyo +audiodev.pyo +antigravity.pyo +dumbdbm.pyo +sndhdr.pyo +__phello__.foo.pyo +sunaudio.pyo +os2emxpath.pyo +multiprocessing/dummy* + +# unused binaries python modules +lib-dynload/termios.so +lib-dynload/_lsprof.so +lib-dynload/*audioop.so +lib-dynload/_hotshot.so +lib-dynload/_heapq.so +lib-dynload/_json.so +lib-dynload/grp.so +lib-dynload/resource.so +lib-dynload/pyexpat.so +lib-dynload/_ctypes_test.so +lib-dynload/_testcapi.so + +# odd files +plat-linux3/regen + +#>sqlite3 +# conditionnal include depending if some recipes are included or not. +sqlite3/* +lib-dynload/_sqlite3.so +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 + + + + + + + + + + + + + + + diff --git a/build-common/0-bundle-all.sh b/build-common/0-bundle-all.sh new file mode 100755 index 0000000..d57c827 --- /dev/null +++ b/build-common/0-bundle-all.sh @@ -0,0 +1,4 @@ +#!/bin/sh +python3 bundle.py $1 +python3 bundle.py -w $1 +python3 bundle.py -b $1 diff --git a/build-common/bundle.py b/build-common/bundle.py new file mode 100644 index 0000000..008e10a --- /dev/null +++ b/build-common/bundle.py @@ -0,0 +1,122 @@ +'Bundle script' + +import os +import sys +import datetime +import re +import zipfile + +bundle_name = 'cat-printer-%s-%s.zip' +edition = 'pure' +version = 'dev' +bundle_sub_dir = 'cat-printer' + +if '-w' in sys.argv: + edition = 'windows' +elif '-b' in sys.argv: + edition = 'bare' + +if not sys.argv[-1].startswith('-'): + version = sys.argv[-1] + +bundle_name %= (edition, version) + +ignore_whitelist = ( + 'www/i18n.js', + 'www/main.comp.js' +) + +additional_ignore = ( + # prevent recurse + bundle_name, + # build helpers + 'build-*', + '?-*.sh', + # no need + '.git', + '.vscode', + '.pylintrc', + '.gitignore', + 'dev-diary.txt', + 'TODO' + # other + '.directory', + 'thumbs.db', + 'thumbs.db:encryptable' +) + +def wildcard_to_regexp(wildcard): + 'Turn a "wildcard" string to a regular expression string' + return ( + wildcard + .replace('/', os.path.sep.replace('\\', '\\\\')) + .replace('.', r'\.') + .replace('*', r'.*') + .replace('?', r'.?') + ) + +os.chdir('../') + +ignored = [] + +for i in additional_ignore: + ignored.append( + re.compile( + wildcard_to_regexp(i) + ) + ) + +if os.path.isfile('.gitignore'): + with open('.gitignore', 'r', encoding='utf-8') as file: + while True: + line = file.readline() + if not line: + break + line = line.strip() + if ( + line.startswith('#') or + line in ignore_whitelist or + not line + ): + continue + pattern = re.compile( + wildcard_to_regexp(line) + ) + ignored.append(pattern) + +with zipfile.ZipFile(bundle_name, 'w', zipfile.ZIP_DEFLATED) as bundle: + for path, dirs, files in os.walk('.'): + for name in files: + fullpath = os.path.join(path, name) + if name == bundle_name: + continue + for pattern in ignored: + if re.search(pattern, fullpath) is not None: + break + else: # if didn't break + bundle.write(fullpath, os.path.join(bundle_sub_dir, fullpath)) + os.chdir('build-common') + if edition != 'bare': + for path, dirs, files in os.walk('bleak'): + if path.endswith('__pycache__'): + continue + for name in files: + fullpath = os.path.join(path, name) + bundle.write(fullpath, os.path.join(bundle_sub_dir, fullpath)) + if edition == 'windows': + os.chdir('python-win32-amd64-embed') + for path, dirs, files in os.walk('.'): + if path.endswith('__pycache__'): + continue + for name in files: + fullpath = os.path.join(path, name) + bundle.write(fullpath, os.path.join(bundle_sub_dir, fullpath)) + os.chdir('..') + bundle.write('start.bat') + bundle.comment = ( + b'Cat Printer "%s" bundle\n%s' % ( + edition.encode('utf-8'), + str(datetime.datetime.now()).encode('utf-8'), + ) + ) + bundle.close() diff --git a/build-common/start.bat b/build-common/start.bat new file mode 100644 index 0000000..99bbf04 --- /dev/null +++ b/build-common/start.bat @@ -0,0 +1,5 @@ +@echo off +color f0 +title Cat Printer - Console +cd cat-printer +python server.py diff --git a/dev-diary.txt b/dev-diary.txt new file mode 100644 index 0000000..c6d3247 --- /dev/null +++ b/dev-diary.txt @@ -0,0 +1,57 @@ + +YEAR 2022 + +MARCH + +31st + +Well, frontend development is just ongoing. + +APRIL + +1st + +Costed one hour try to find an event propagation problem, +but finally found it's a matter of CSS selector. + +BTW frontend can communicate with backend now. + +2nd + +(Formally) Started playing with Android. + +Costed 4 hours to find why Java class (on android) always can't be found, +but finally realized pyjnius can't work with python thread/subprocess, +while I'm using ThreadingHTTPServer. + +See "Note" around: + https://python-for-android.readthedocs.io/en/latest/buildoptions/#webview + +NEVER MISS ANY SENTENCE OF A DOCUMENT + +3rd + +Costed the whole day to solve WebView problem -- + it doesn't do reaction to by default. + +Finally done with library AdvancedWebView, but still with some hacks. + +... It's now 4th 2:00 a.m. though + +4th + +Purged many small problems. + +Wished to release it today, but no luck... + +5th + +Bundle script, optimized project structure. + +It's finally ready... + +6th + +Documentation. + +What else? First Release! diff --git a/development.md b/development.md new file mode 100644 index 0000000..31b6462 --- /dev/null +++ b/development.md @@ -0,0 +1,115 @@ + +# Development + +## Overview + +This application have a Client/Server module, but it's just locally. + +The backend is in Python 3, aiming to have fewest dependencies, and in fact currently have just `bleak`. +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. + +The Android version is built with [python-for-android](https://python-for-android.readthedocs.io/en/latest/). +In our case it's **NOT** something easy, don't go blindly if you don't want to waste your time. +There are too many hacks to be done, before and after. Let me summarize them later... +By the way, feel free to look at file `dev-diary.txt` + +## Get Dependencies + +### Basic + +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. +For more, read on... + +### Additional + +Sorry, I'm not a dev package manager enthusiast. + +If there are something better to organize these, feel free to discuss in issue. + +- Install TypeScript on Node.js `npm` + `npm --global install typescript` + You may need root privilege on GNU/Linux (i.e. prefix `sudo`) + Now the `0-transpile.sh` will work, you're ready to deal with compatibility +- Put the Bleak pip installation as `build-common/bleak` + - You need this in order to bundle a "pure" or "windows" release + - See [Files](#files) section about `bundle.py` +- 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 + - 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 + - Handles API requests via `POST` + - Handles frontend configuration + - Interacts with `printer.py`, for the printer driver +- `printer.py` - The core printer driver: + - Have the `PrinterDriver` class, to be reused + - Have a command-line interface. Can be invoked in a shell, to do things directly +- `.pylintrc` - Pylint RC file: + - Include it for better experience browsing the code + +- `www/main.js` - Main frontend script: + - The script for direct modification in development + - No need to care "compatibility". Transpile the scripts when release. +- `www/image.js` - Image manipulation functions: + - Implementations for some grayscale/monochrome filters on a image (HTML `` `ImageData`) + - And PBM image file format, a very simple mono bitmap format. +- `www/main.comp.js` - Compatibility script: + - 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/*.js` - Other scripts: + - Small but useful, just look at them directly +- `www/jslicense.html` - Dedicated JavaScript License information +- `www/lang/*.json` - Languages + +- `N-*.sh` - Shell files: + - Helpers for development convenience + - 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 + - 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` + - Bundle a "windows" edition with `-w` switch in place of `-b` + - You may put a version code as last parameter + - Resulting zip files will be in repo's root directory +- `build-common/0-bundle-all.sh` - Bundle all editions at once + +- `build-android/0-build-android.sh` - The dev build script: + - Invokes `python-for-android` + - Defines many things + - Just builds using the current repo state + - **Doesn't** work out-of-the-box. Again, please wait for me to summarize the hacks... +- `build-android/3-formal-build.sh` - The "formal" build script: + - Unlike the dev version, this needs a pre-built "bare" edition zip, and should be passed a version id (like `0.1.0`) + - Also unlike dev, this doesn't enforce the custom blacklist, since "bare" is already minimal + +### Be aware that... + +If there are intermediate development files that are not meant to be in this public repo, please add to `.gitignore` diff --git a/main.py b/main.py new file mode 100644 index 0000000..098dc2a --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +'For python-for-android entry point' + +from server import serve + +serve() diff --git a/printer.py b/printer.py index 03d34a7..0fd2933 100644 --- a/printer.py +++ b/printer.py @@ -1,6 +1,16 @@ +'Cat-Printer' + +import io +import sys +import argparse import asyncio -from bleak import BleakClient -import sys, os, io +from bleak import BleakClient, BleakScanner +from bleak.exc import BleakError, BleakDBusError + +class PrinterError(Exception): + 'Error of Printer driver' + +models = ('GB01', 'GB02', 'GT01') crc8_table = [ 0x00, 0x07, 0x0e, 0x09, 0x1c, 0x1b, 0x12, 0x15, 0x38, 0x3f, 0x36, 0x31, @@ -27,28 +37,62 @@ 0xfa, 0xfd, 0xf4, 0xf3 ] + def crc8(data): + 'crc8 hash' crc = 0 for byte in data: crc = crc8_table[(crc ^ byte) & 0xFF] return crc & 0xFF + +def set_attr_if_not_none(obj, attrs): + ''' set the attribute of `obj` if the value is not `None` + `attrs` is `dict` of attr-value pair + ''' + for name in attrs: + value = attrs[name] + if value is not None: + setattr(obj, name, value) + + +def reverse_binary(value): + '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, payload): + 'Make a `bytes` with command data, which can be sent to printer directly to operate' + if len(payload) > 0x100: + raise Exception('Too large payload') + message = bytearray([0x51, 0x78, command, 0x00, len(payload), 0x00]) + message += payload + message.append(crc8(payload)) + message.append(0xFF) + return bytes(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 - DrawBitmap = 0xA2 # Data: Line to draw. 0 bit -> don't draw pixel, 1 bit -> draw pixel + # 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 height: int data: bytes args: dict - def __init__(self, width=384, height=0, data=b'', args={}): + + def __init__(self, width: int, height: int, data: bytes, args: dict = None): self.width = width self.height = height self.data = data @@ -57,109 +101,114 @@ def __init__(self, width=384, height=0, data=b'', args={}): PrinterCommands.SetEnergy: b'\xE0\x2E', PrinterCommands.SetQuality: b'\x05' } - for i in args: - self.args[i] = args[i] + if args: + for arg in args: + self.args[arg] = args[arg] + class PrinterDriver(): + 'Manipulator of the printer' - mtu: int + 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' - feed_after: int + dry_run = False + 'Is dry run (emulate print process but print nothing)' standard_width = 384 + 'It\'s a constant for the printer' + + pbm_data_per_line = int(standard_width / 8) # 48 + 'Constant, determined by standard width & PBM data format' - standard_pbm_data_length_per_line = int(standard_width / 8) # 48 - characteristic = '0000ae01-0000-1000-8000-00805f9b34fb' - - def __init__(self, mtu=200, feed_after=128): - self.mtu = mtu - self.feed_after = feed_after - - def _reverse_binary(self, value): - return int(f"{bin(value)[2:]:0>8}"[::-1], 2) - - def _make_command(self, command, payload): - if len(payload) > 0x100: - raise Exception('Too large payload') - message = bytearray([0x51, 0x78, command, 0x00, len(payload), 0x00]) - message += payload - message.append(crc8(payload)) - message.append(0xFF) - return bytes(message) - - def _read_pbm(self, path='', data=b''): - if path != '': - f = open(path, 'rb') - elif data != b'': - f = io.BytesIO(data) + 'The BLE characteristic, a constant of the printer' + + mtu = 200 + + 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') + elif data is not None: + file = io.BytesIO(data) else: - f = sys.stdin.buffer - signature = f.readline() + file = sys.stdin.buffer + signature = file.readline() if signature != b'P4\n': raise Exception('Specified file is not a PBM image') width, height = self.standard_width, 0 - args = {} while True: - l = f.readline()[0:-1] - if l[0:1] == b'#': - if l[1:2] == b'!': - inline_args = l[2:].split(b',') - args[PrinterCommands.DrawingMode] = bytes([int(inline_args[0], 16)]) - args[PrinterCommands.SetEnergy] = bytes([int(inline_args[1], 16)]) - args[PrinterCommands.SetQuality] = bytes([int(inline_args[2], 16)]) - continue - width, height = [int(x) for x in l.split(b' ')[0:2]] - if width != self.standard_width: - raise Exception('PBM image width is not 384px') - break - data = f.read() - len_data = len(data) - if len_data != height * self.standard_pbm_data_length_per_line: + # There can be comments. Skip them + 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.standard_width: + raise Exception('PBM image width is not 384px') + expected_data_size = self.pbm_data_per_line * height + data = file.read() + if path is not None and path != '-': + file.close() + data_size = len(data) + if data_size != expected_data_size: raise Exception('Broken PBM file data') - return PBMData(width, height, data, args) - + if self.dry_run: + # Dry run: put blank data + data = b'\x00' * expected_data_size + return PBMData(width, height, data) + def _pbm_data_to_raw(self, data: PBMData): buffer = bytearray() - for i in data.args: - buffer += self._make_command(i, data.args[i]) - buffer += self._make_command( + 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]) + 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.standard_pbm_data_length_per_line : - (i + 1) * self.standard_pbm_data_length_per_line + i * self.pbm_data_per_line: + (i + 1) * self.pbm_data_per_line ] if i % 200 == 0: - buffer += self._make_command( + buffer += make_command( PrinterCommands.LatticeControl, - bytearray([0xAA, 0x55, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x17]) + bytearray([0xAA, 0x55, 0x17, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x17]) ) - # buffer += self._make_command( + # buffer += make_command( # PrinterCommands.UpdateDevice, # bytes([0x00]) # ) - buffer += self._make_command( + buffer += make_command( PrinterCommands.DrawBitmap, - bytes([self._reverse_binary(x) for x in data_for_a_line]) + bytes([reverse_binary(x) for x in data_for_a_line]) ) - buffer += self._make_command( + buffer += make_command( PrinterCommands.LatticeControl, - bytearray([0xAA, 0x55, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x17]) + bytearray([0xAA, 0x55, 0x17, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x17]) ) if self.feed_after > 0: - buffer += self._make_command( + buffer += make_command( PrinterCommands.FeedPaper, bytes([self.feed_after % 256, self.feed_after // 256]) ) return buffer - - async def _send_buffer(self, buffer: bytearray, address: str): - client = BleakClient(address) + + async def send_buffer(self, buffer: bytearray, address: str): + 'Send manipulation data (buffer) to the printer via bluetooth' + client = BleakClient(address, timeout=5.0) await client.connect() - # await client.write_gatt_char(self.characteristic, self._make_command(PrinterCommands.FeedPaper, bytearray([0, 0]))) count = 0 total = len(buffer) // self.mtu while True: @@ -168,37 +217,104 @@ async def _send_buffer(self, buffer: bytearray, address: str): if count < total: await client.write_gatt_char(self.characteristic, buffer[start:end]) if count % 16 == 0: - await asyncio.sleep(0.5) + 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): + ''' Search for all printers around with bluetooth. + Only known-working models will show up. + ''' + 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): + '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): + 'Method to print the specified PBM image at `path` with printer at specified MAC `address`' pbm_data = self._read_pbm(path) buffer = self._pbm_data_to_raw(pbm_data) - await self._send_buffer(buffer, address) + await self.send_buffer(buffer, address) async def print_data(self, data: bytes, address: str): - pbm_data = self._read_pbm('', data) + 'Method to print the specified PBM image `data` with printer at specified MAC `address`' + pbm_data = self._read_pbm(None, data) buffer = self._pbm_data_to_raw(pbm_data) - await self._send_buffer(buffer, address) + await self.send_buffer(buffer, address) -if __name__ == '__main__': - len_argv = len(sys.argv) + +async def _main(): + 'Main routine for direct command line execution' + parser = argparse.ArgumentParser( + description='''Print an PBM image to a Cat/Kitty Printer, of model GB01, GB02 or GT01.''' + ) + parser.add_argument('file', default='-', metavar='FILE', type=str, + help='PBM image file to print, use \'-\' to read from stdin') + exgr = parser.add_mutually_exclusive_group() + exgr.add_argument('-s', '--scan', metavar='DELAY', default=3.0, required=False, type=float, + help='Scan for a printer for specified seconds') + exgr.add_argument('-a', '--address', metavar='xx:xx:xx:xx:xx:xx', required=False, type=str, + help='The printer\'s bluetooth MAC address') + parser.add_argument('-p', '--feed', required=False, type=int, + help='Extra paper to feed after printing') + parser.add_argument('-f', '--freq', required=False, type=float, + help='Communication frequency, in seconds. ' + + 'set a bit higher (eg. 1 or 1.2) if printed content is teared/have gaps') + parser.add_argument('-d', '--dry', required=False, action='store_true', + help='Emulate the printing process, but actually print nothing ("dry run")') + parser.add_argument('-m', '--mtu', required=False, type=int, + help='MTU of bluetooth packet (Advanced)') + cmdargs = parser.parse_args() + addr = cmdargs.address printer = PrinterDriver() - loop = asyncio.get_event_loop() - if len_argv == 1: - print( - 'Usage: %s [PBM files to print...]\n' % os.path.basename(__file__) + - '\tPrint PBM files to a Cat Printer\n' + - '\tInput MAC address and file paths\n' + - '\tInputing file to stdin is also supported' - ) - else: - if len_argv == 2: - loop.run_until_complete(printer.print_file('', sys.argv[1])) - elif len_argv >= 3: - for i in sys.argv[2:]: - loop.run_until_complete(printer.print_file(i, sys.argv[1])) + if not addr: + print('Cat Printer :3') + print(f' * Finding printer devices via bluetooth in {cmdargs.scan} seconds') + device = await printer.search_printer(cmdargs.scan) + if device is not None: + print(f' * Will print through {device.name} {device.address}') + else: + print(' ! No device found. Please check if the printer is powered on.') + print(' ! Or try to scan longer with \'-s 6.0\'') + sys.exit(1) + if cmdargs.dry: + print(' * DRY RUN') + set_attr_if_not_none(printer, { + 'feed_after': cmdargs.feed, + 'frequency': cmdargs.freq, + 'mtu': cmdargs.mtu, + 'dry': cmdargs.dry + }) + await printer.print_file(cmdargs.file, addr) + +async def main(): + 'Run the `_main` routine while catching exceptions' + try: + await _main() + except BleakError as e: + error_message = str(e) + if ( + 'not turned on' in error_message or + (isinstance(e, BleakDBusError) and + getattr(e, 'dbus_error') == 'org.bluez.Error.NotReady') + ): + print(' ! Please enable bluetooth on this machine :3') + sys.exit(1) + else: + raise + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/server.py b/server.py index c2617da..739abb5 100644 --- a/server.py +++ b/server.py @@ -1,215 +1,256 @@ +'Cat Printer - Serve a Web UI' -from http.server import HTTPServer, BaseHTTPRequestHandler -import socketserver, threading, urllib, os, asyncio, tempfile, platform +# if pylint is annoying you, see file .pylint-rc + +import os +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 -import bleak -def urlvar(path): - a = path.split('?') - d = [] - f = {} - if len(a) > 1: - b = a[1].split('&') - for i in b: - d.append(i.split('=')) - for i in d: - if len(i) == 1: - i.append('1') - f[i[0]] = i[1] - return f +class DictAsObject(dict): + """ Let you use a dict like an object in JavaScript. + """ + def __getattr__(self, key): + return self.get(key, None) + def __setattr__(self, key, value): + self[key] = value + +class PrinterServerError(Exception): + '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] + +Printer = PrinterDriver() +server = None + +def log(message): + 'For logging a message' + print(message) -mimetypes = { - 'html': 'text/html', - 'txt': 'text/plain', - 'js': 'text/javascript', - 'css': 'text/css' +mime_type = { + 'html': 'text/html;charset=utf-8', + 'css': 'text/css;charset=utf-8', + 'js': 'text/javascript;charset=utf-8', + 'txt': 'text/plain;charset=utf-8', + 'json': 'application/json;charset=utf-8', + 'png': 'image/png', + 'octet-stream': 'application/octet-stream' } -def getmime(path): - global mimetypes - ext = path.split('.')[-1] - return mimetypes.get(ext, 'application/octet-stream') +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' buffer = 4 * 1024 * 1024 - driver = PrinterDriver() + max_payload = buffer * 16 + printer_address: str = None + settings = DictAsObject({ + 'config_path': 'config.json', + 'is_android': False, + 'printer_address': None, + 'scan_time': 3, + 'frequency': 0.8, + 'dry_run': False + }) + 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 if self.path == '/': - self.path = '/index.html' - path = urllib.parse.unquote(self.path) - # v = urlvar(path) - path = path.split('?')[0] - if len(path) >= 2: - if path[0:2] == '/~': - action = path[2:] - if action == 'getdevices': - try: - devices = asyncio.run(bleak.BleakScanner.discover()) - self.send_response(200) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write('\n'.join([('%s,%s' % (x.name, x.address)) for x in devices]).encode('utf-8')) - except Exception as e: - self.send_response(500) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(str(e).encode('utf-8')) - else: - # local file - path = 'www/' + path[1:] - if os.path.exists(path): - self.send_response(200) - self.send_header('Content-Type', getmime(path)) - # self.send_header('Cache-Control', 'public, max-age=86400') - self.end_headers() - with open(path, 'rb') as f: - while True: - data = f.read(self.buffer) - if data: - self.wfile.write(data) - else: - break - return - else: - self.send_response(404) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(b'Not Found') - return - def do_POST(self): - if self.headers.get('Content-Type', '') == 'application/ipp': - # https://datatracker.ietf.org/doc/html/rfc8010 - self.handle_ipp() + path += 'index.html' + if '/..' in path: return - path = urllib.parse.unquote(self.path) - v = urlvar(path) - path = path.split('?')[0] - if len(path) >= 2: - if path[0:2] == '/~': - action = path[2:] - if action == 'print': - if 'mtu' in v: - self.driver.mtu = v['mtu'] - if 'feed_after' in v: - self.driver.feed_after = v['feed_after'] - try: - content_length = int(self.headers.get('Content-Length')) - data = self.rfile.read(content_length) - asyncio.run(self.driver.print_data(data, v['address'])) - self.send_response(200) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(b'OK') - except Exception as e: - self.send_response(500) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(str(e).encode('utf-8')) - else: - self.send_response(400) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(b'Bad Request') - def handle_ipp(self): - path = urllib.parse.unquote(self.path) - printer_name = path[1:] - data = self.rfile.read(int(self.headers.get('Content-Length', 0))) - # 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] - attributes = {} - data_to_print = b'' - # b'\x01'[0] == int(1) - if ipp_operation_attributes_tag == b'\x01'[0]: - pointer = 9 - next_name_length_at = 10 - next_value_length_at = 10 - name = b'' - value = b'' - while data[pointer] != b'\x03'[0]: - tag = data[pointer:pointer + 1] - pointer += 1 - if tag[0] < 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 - attributes[name] = (tag, value) - name = b'' - value = b'' - pointer += 1 - data_to_print = data[pointer:] - if data_to_print == b'': - self.send_response(200) - self.send_header('Content-Type', 'application/ipp') + if not os.path.isfile(path): + self.send_response(404) + self.send_header('Content-Type', mime('txt')) self.end_headers() - self.wfile.write( - b'\x01\x01\x00\x00\x00\x00\x00\x01\x01\x03' + return + self.send_response(200) + self.send_header('Content-Type', mime(path)) + # self.send_header('Content-Size', str(os.stat(path).st_size)) + self.end_headers() + with open(path, 'rb') as file: + while True: + chunk = file.read(self.buffer) + if not self.wfile.write(chunk): + break + return + def api_success(self): + 'Called when a simple 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): + '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: + self.settings['is_android'] = True + from android.storage import app_storage_path # pylint: disable=import-error + settings_path = app_storage_path() + os.makedirs(settings_path, exist_ok=True) + self.settings['config_path'] = os.path.join( + settings_path, 'config.json' ) + 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)) + 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) + def handle_api(self): + 'Handle API request from POST' + content_length = int(self.headers.get('Content-Length')) + body = self.rfile.read(content_length) + api = self.path[1:] + if api == 'print': + if self.settings.printer_address is None: + # usually can't encounter, though + raise PrinterServerError('No printer address specified') + Printer.dry_run = self.settings.dry_run + Printer.frequency = float(self.settings.frequency) + loop = asyncio.new_event_loop() + try: + devices = loop.run_until_complete( + Printer.print_data(body, self.settings.printer_address) + ) + self.api_success() + finally: + loop.close() return - try: - devices = asyncio.run(bleak.BleakScanner.discover()) - target_device = '' - for i in devices: - if i.name == printer_name: - target_device = i.address - if target_device != '': - platform_system = platform.system() - temp_dir = tempfile.mkdtemp() - temp_file_ps = os.path.join(temp_dir, 'temp.ps') - temp_file_pbm = os.path.join(temp_dir, 'temp.pbm') - f = open(temp_file_ps, 'wb') - f.write(data_to_print) - f.close() - # https://ghostscript.com/doc/9.54.0/Use.htm#Output_device - ghostscript_exe = 'gs' - if platform_system == 'Windows': - ghostscript_exe = 'gswin32c.exe' - elif platform_system == 'Linux': - ghostscript_exe = 'gs' - elif platform_system == 'OS/2': - ghostscript_exe = 'gsos2' - return_code = os.system('%s -q -sDEVICE=pbmraw -dNOPAUSE -dBATCH -dSAFER -dFIXEDMEDIA -g384x543 -r46.4441219158x46.4441219158 -dFitPage -sOutputFile="%s" "%s"' % (ghostscript_exe, temp_file_pbm, temp_file_ps)) - if return_code == 0: - asyncio.run(self.driver.print_file(temp_file_pbm, target_device)) - else: - raise Exception('Error on invoking Ghostscript') - # print(data_to_print) + data = DictAsObject(json.loads(body)) + if api == 'devices': + loop = asyncio.new_event_loop() + try: + devices = loop.run_until_complete( + Printer.search_all_printers(float(self.settings.scan_time)) + ) + finally: + loop.close() + devices_list = [{ + 'name': device.name, + 'address': device.address + } for device in devices] self.send_response(200) - self.send_header('Content-Type', 'application/ipp') + self.send_header('Content-Type', mime('json')) self.end_headers() - self.wfile.write( - b'\x01\x01\x00\x00\x00\x00\x00\x01\x01\x03' - ) - except Exception as _: - self.send_response(500) - self.send_header('Content-Type', 'application/ipp') + self.wfile.write(json.dumps({ + '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(b'') - - -class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer): - """ Handle requests in a separate thread. """ + self.wfile.write(json.dumps(self.settings).encode('utf-8')) + return + if api == 'set': + for key in data: + self.settings[key] = data[key] + self.save_config() + self.api_success() + return + if api == 'exit': + self.api_success() + self.save_config() + # Only usable when using ThreadingHTTPServer + # server.shutdown() + 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 + content_length > self.max_payload + ): + self.send_response(400) + self.send_header('Content-Type', mime('txt')) + self.end_headers() + return + try: + self.handle_api() + return + except BleakDBusError as e: + 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: + self.api_fail({ + 'code': e.code, + 'name': e.name, + 'details': e.details + }) + except Exception as e: + self.api_fail({ + 'code': -1, + 'name': 'Exception', + 'details': str(e) + }, e) -if __name__ == '__main__': - address, port = '', 8095 - server = ThreadedHTTPServer((address, port), PrinterServer) +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') + listen_all = True + global server + # Again, Don't use ThreadingHTTPServer if you're going to use pyjnius! + server = HTTPServer(('' if listen_all else address, port), PrinterServer) + service_url = f'http://{address}:{port}/' + if '-s' in sys.argv: + print(service_url) + else: + operating_system = platform.uname().system + if operating_system == 'Windows': + os.system(f'start {service_url} > NUL') + elif operating_system == 'Linux': + os.system(f'xdg-open {service_url} &> /dev/null') + # TODO: I don't know about macOS + # elif operating_system == 'macOS': + else: + print(f'Will serve application at: {service_url}') try: - # Start a thread with the server -- that thread will then start one - # more thread for each request - server_thread = threading.Thread(target=server.serve_forever) - # Exit the server thread when the main thread terminates - server_thread.daemon = True - server_thread.start() - print('http://localhost:8095/') server.serve_forever() except KeyboardInterrupt: pass + +if __name__ == '__main__': + serve() diff --git a/www/_load.html b/www/_load.html new file mode 100644 index 0000000..e39148b --- /dev/null +++ b/www/_load.html @@ -0,0 +1,28 @@ + + + + + + + Python Webview Loading + + + + + + \ No newline at end of file diff --git a/www/custom-print.html b/www/custom-print.html deleted file mode 100644 index e45a030..0000000 --- a/www/custom-print.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - Custom Print - - - - -
-

- -

-

Custom Print

-

- - Select device: -

-

- - Operate directly on the box canvas - - -
- Canvas Height: - -
- Threshold: - -
-
- - Insert: - - - -
- Action: - - - - - -
-

-
- -
-

- Preview
- -

-
- - - - - - - - diff --git a/www/custom-print.js b/www/custom-print.js deleted file mode 100644 index 22386ad..0000000 --- a/www/custom-print.js +++ /dev/null @@ -1,141 +0,0 @@ -/// -/// - -class CustomPrinter { - WIDTH = 384; - threshold = 0.6; - bluetoothMACInput = document.getElementById('bluetooth_address_input'); - thresholdInput = document.getElementById('filter_threshold'); - fileSelection = document.createElement('input'); - dummyImage = new Image(); - canvasPreview = document.getElementById('image_preview'); - previewButton = document.getElementById('preview_button'); - printButton = document.getElementById('print_button'); - actionInsertText = document.getElementById('action_insert_text'); - actionInsertImage = document.getElementById('action_insert_image'); - actionInsertQR = document.getElementById('action_insert_qr'); - canvasHeightInput = document.getElementById('canvas_height'); - fabricCanvas = new fabric.Canvas('work_canvas', { - backgroundColor: 'white' - }); - monoMethod = imageDataColorToMonoSquare; - insertImage() { - this.fileSelection.click(); - } - preview() { - let context = this.fabricCanvas.getContext('2d'); - let imagedata = context.getImageData(0, 0, this.WIDTH, this.fabricCanvas.height); - this.canvasPreview.height = this.fabricCanvas.height; - this.canvasPreview.getContext('2d').putImageData(this.monoMethod(imagedata, this.threshold), 0, 0); - } - constructor() { - this.fileSelection.type = 'file'; - this.monoMethod = imageDataColorToMonoSquare; - this.fileSelection.addEventListener('input', this.preview.bind(this)); - this.previewButton.addEventListener('click', this.preview.bind(this)); - this.thresholdInput.onchange = event => { - this.threshold = this.thresholdInput.value; - } - this.canvasHeightInput.onchange = event => { - this.fabricCanvas.setHeight(this.canvasHeightInput.value); - } - this.actionInsertText.addEventListener('click', event => { - let text = new fabric.Textbox(i18N.get('Double click to edit'), { - color: 'black', - fontSize: 24, - }); - this.fabricCanvas.add(text); - }); - this.fileSelection.addEventListener('input', event => { - let reader = new FileReader(); - reader.onload = event1 => { - this.dummyImage.src = event1.target.result; - let fimage = new fabric.Image(this.dummyImage, {}); - fimage.scale(this.WIDTH / this.dummyImage.width); - this.fabricCanvas.add(fimage); - } - reader.readAsDataURL(this.fileSelection.files[0]); - }); - this.actionInsertImage.addEventListener('click', this.insertImage.bind(this)); - this.actionInsertQR.addEventListener('click', event => { - let div = document.createElement('div'); - new QRCode(div, prompt(i18N.get('Content of QRCode:'))); - // QRCode generation is async, currently have no better way than waiting for a while - setTimeout(() => { - let fimage = new fabric.Image(div.lastChild, { - left: this.WIDTH / 4, - top: this.WIDTH / 4 - }); - fimage.scale((this.WIDTH / 2) / div.lastChild.width); - this.fabricCanvas.add(fimage); - }, 1000); - }); - this.printButton.addEventListener('click', event => { - // this.preview(); - if (this.canvasPreview.height == 0) { - notice(i18N.get('Please preview image first')); - return; - } - let mac_address = this.bluetoothMACInput.value; - if (mac_address == '') { - notice(i18N.get('Please select a device')); - return; - } - notice(i18N.get('Printing, please wait.')); - let context = this.canvasPreview.getContext('2d'); - let pbm_data = imageDataMonoToPBM(context.getImageData(0, 0, this.WIDTH, this.canvasPreview.height)); - let xhr = new XMLHttpRequest(); - xhr.open('POST', '/~print?address=' + mac_address); - xhr.setRequestHeader('Content-Type', 'application-octet-stream'); - xhr.onload = () => { - notice(i18N.get(xhr.responseText)); - } - xhr.send(pbm_data); - }); - let boldFunction = () => { - let object = this.fabricCanvas.getActiveObject(); - if (!object) return; - if (object.type == 'textbox') { - if (object.fontWeight == 'normal') object.fontWeight = 'bold'; - else if (object.fontWeight == 'bold') object.fontWeight = 'normal'; - this.fabricCanvas.renderAll(); - } - } - document.getElementById('action_make_bold').addEventListener('click', boldFunction.bind(this)); - let italicFunction = () => { - let object = this.fabricCanvas.getActiveObject(); - if (!object) return; - if (object.type == 'textbox') { - if (object.fontStyle == 'normal') object.fontStyle = 'italic'; - else if (object.fontStyle == 'italic') object.fontStyle = 'normal'; - this.fabricCanvas.renderAll(); - } - } - document.getElementById('action_make_italic').addEventListener('click', italicFunction.bind(this)); - document.getElementById('action_make_underline').addEventListener('click', event => { - let object = this.fabricCanvas.getActiveObject(); - if (!object) return; - if (object.type == 'textbox') { - object.underline = !object.underline; - // Seems there's a bug in fabric, underline cannot be rendered before changing bold/italic - boldFunction(); - this.fabricCanvas.renderAll(); - boldFunction(); - this.fabricCanvas.renderAll(); - } - }); - document.getElementById('action_delete').addEventListener('click', event => { - let object = this.fabricCanvas.getActiveObject(); - if (!object) return; - this.fabricCanvas.remove(object); - this.fabricCanvas.renderAll(); - }); - this.fabricCanvas.freeDrawingBrush.color = 'black'; - this.fabricCanvas.freeDrawingBrush.width = 6; - document.getElementById('action_switch_paint').addEventListener('click', event => { - this.fabricCanvas.isDrawingMode = !this.fabricCanvas.isDrawingMode; - }) - } -} - -var custom_printer = new CustomPrinter(); diff --git a/www/help/help.en-US.html b/www/help/help.en-US.html deleted file mode 100644 index 5ab909d..0000000 --- a/www/help/help.en-US.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - Help - - - -
-

Help

-

Here is some useful information.

-

Note

-
    -
  • Preview (and printed) image is mono-colored. Only black and white.
  • -
  • Threshold is the "darkness" required for image pixels to turning black. The higher it is, the image is brighter.
  • -
-
-

Function

-

Print Image

-

You can print an image to cat printer in that page.

-

Select a photo, click preview, check the preview and adjust threshold.

-

When satisfied, select printer device and print!

-

Print Document

-

Simply copy-paste your document content from office software to that page, the format is preserved and the result can be previewed.

-

Image can't be there because of security policy. Please use "Custom Print" if necessory.

-

Custom Print

-

You can freely arrange the content of a canvas then print it. Insert/edit text, image, or QRCode.

-
-
- - \ No newline at end of file diff --git a/www/help/help.zh-CN.html b/www/help/help.zh-CN.html deleted file mode 100644 index ce4df85..0000000 --- a/www/help/help.zh-CN.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - 帮助 - - - -
-

帮助

-

这里有一些有用的信息。

-

注记

-
    -
  • 预览与打印出的图像是单色的。只有黑色和白色。
  • -
  • 阈值是像素变为黑色所需的“黑度”。它的值越高,图像越亮。
  • -
-
-

功能

-

打印图片

-

您可以在此页将图像打印至猫咪打印机。

-

选择图片,点击预览,检查预览并调整阈值。

-

感觉满意后,选择打印机设备并打印!

-

打印文档

-

只需把文档从办公软件复制粘贴到此页,文档格式被保留且可以预览。

-

由于安全策略,无法粘贴图像。如果需要请使用“自定义打印”。

-

自定义打印

-

您可以自由排布画布上的内容并打印。插入/编辑文字、图像或二维码。

-
-
- - \ No newline at end of file diff --git a/www/help/index.html b/www/help/index.html deleted file mode 100644 index 3c70f97..0000000 --- a/www/help/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - Help - - - -

Help Documents

- -
- - \ No newline at end of file diff --git a/www/icon.png b/www/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d46ffb1cf2fcb7086c56b0a67ec6a0535953dd46 GIT binary patch literal 4212 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 t ? 255 : 0; + } + } +} + +/** + * The widely used Floyd Steinberg algorithm, the most "natual" one. + * @param {Uint8ClampedArray} data the grayscale data, mentioned in `monoGrayscale`. **will be modified in-place** + * @param {number} w width of image + * @param {number} h height of image + * @param {number} t threshold + */ +function monoSteinberg(data, w, h, t) { + let p, m, n, o; + function adjust(x, y, delta) { + if ( + x < 0 || x >= w || + y < 0 || y >= h + ) return; + p = y * w + x; + data[p] += delta; + } + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + p = j * w + i; + m = data[p]; + n = m > t ? 255 : 0; + o = m - n; + data[p] = n; + adjust(i + 1, j , o * 7 / 16); + adjust(i - 1, j + 1, o * 3 / 16); + adjust(i , j + 1, o * 5 / 16); + adjust(i + 1, j + 1, o * 1 / 16); + } + } +} + +/** + * (Work in Progress...) + */ +function monoHalftone(data, w, h, t) {} + +/** + * My own toy algorithm used in old versions. Not so natual. + * It have 2 pass, horizonally and vertically. + * @param {Uint8ClampedArray} data the grayscale data, mentioned in `monoGrayscale`. **will be modified in-place** + * @param {number} w width of image + * @param {number} h height of image + * @param {number} t threshold + */ +function monoLegacy(data, w, h, t) { + let data_h = data.slice(); + let data_v = data.slice(); + monoLegacyH(data_h, w, h, t); + monoLegacyV(data_v, w, h, t); + for (let i = 0; i < data.length; i++) { + data[i] = data_h[i] & data_v[i]; + } +} +function monoLegacyH(data, w, h, t) { + let v = 0, p; + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + p = j * w + i; + v += data[p]; + if (v >= t) { + data[p] = 255; + v = 0; + } else data[p] = 0; + } + v = 0; + } +} +function monoLegacyV(data, w, h, t) { + let v = 0, p; + for (let i = 0; i < w; i++) { + for (let j = 0; j < h; j++) { + p = j * w + i; + v += data[p]; + if (v >= t) { + data[p] = 255; + v = 0; + } else data[p] = 0; + } + v = 0; + } +} + +/** + * Slightly modified from `monoLegacy`, but still messy. + * But, try the horizonal and vertical sub algorithm! + * @param {Uint8ClampedArray} data the grayscale data, mentioned in `monoGrayscale`. **will be modified in-place** + * @param {number} w width of image + * @param {number} h height of image + * @param {number} t threshold + */ +function monoNew(data, w, h, t) { + let data_h = data.slice(); + let data_v = data.slice(); + monoNewH(data_h, w, h, t); + monoNewV(data_v, w, h, t); + for (let i = 0; i < data.length; i++) { + data[i] = data_h[i] & data_v[i]; + } +} +function monoNewH(data, w, h, t) { + t = (t - 127) / 4 + 1; + let v = 0, p; + for (let j = 0; j < w; j++) { + for (let i = 0; i < h; i++) { + p = j * h + i; + v += data[p] + t; + if (v >= 255) { + data[p] = 255; + v -= 255; + } else data[p] = 0; + } + v = 0; + } +} +function monoNewV(data, w, h, t) { + t = (t - 127) / 4 + 1; + let v = -1, p; + for (let i = 0; i < h; i++) { + for (let j = 0; j < w; j++) { + p = j * h + i; + v += data[p] + t; + if (v >= 255) { + data[p] = 255; + v -= 255; + } else data[p] = 0; + } + v = 0; + } +} + +/** + * Convert a monochrome image data to PBM mono image file data. + * Returns a Blob containing the file data. + * @param {Uint8ClampedArray} data the data that have a size of `w * h` + * @param {number} w width of image + * @param {number} h height of image + * @returns {Blob} + */ +function mono2pbm(data, w, h) { + let result = new Uint8ClampedArray(data.length / 8); + let slice, p; + for (let i = 0; i < result.length; i++) { + p = i * 8; + slice = data.slice(p, p + 8); + // Merge 8 bytes to 1 byte, and negate the bits + // expecting in the data there's only 255 (0b11111111) or 0 (0b00000000) + result[i] = ( + slice[0] & 0b10000000 | + slice[1] & 0b01000000 | + slice[2] & 0b00100000 | + slice[3] & 0b00010000 | + slice[4] & 0b00001000 | + slice[5] & 0b00000100 | + slice[6] & 0b00000010 | + slice[7] & 0b00000001 + ) ^ 0b11111111; + } + let pbm_data = new Blob([`P4\n${w} ${h}\n`, result]); + return pbm_data; +} diff --git a/www/index.html b/www/index.html index 28d2abb..56ca0e5 100644 --- a/www/index.html +++ b/www/index.html @@ -2,30 +2,133 @@ - - Printer Center + Cat Printer - + -
-

Printer Center

-
+
+
+

Cat Printer

+
+ +
+
+ Print + + + +
+ + + +
+
+
+ Help +
+

Coming Soon...

+

+ + JavaScript License Information +

+
+
+
+ Settings +
+ Image +
+ +
+ + + +
+ + +
+ + +
+
+ Printer + + + seconds +
+ + +
+ + +
+
+ System + + +
+
+ +
+
+ +
+ + +
+
+
+ +
+ +
+ +
+
- - + + \ No newline at end of file diff --git a/www/jslicense.html b/www/jslicense.html new file mode 100644 index 0000000..f3c8eaa --- /dev/null +++ b/www/jslicense.html @@ -0,0 +1,92 @@ + + + + + + JavaScript License Information + + + + +
+
+

JavaScript License Information

+

You can see all JavaScript used along with this application are Free Software.[1]

+

+ Go Back +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ResourceLicenseSourceDescription
loader.jsCC0-1.0-onlyloader.jsFor dynamically loading other scripts, and fallback if there are problems.
image.jsCC0-1.0-onlyimage.jsContains functions for image manipulation and public algorithms of image monochrome filters.
i18n.jsExpati18n.jsFor internationalization (language support)
main.jsGNU-GPL-3.0-or-latermain.jsThe main script for Cat-Printer
polyfill.jsCC0-1.0-onlypolyfill.jsPut features that are not supported by old browsers.
main.comp.jsGNU-GPL-3.0-or-latermain.jsA bundle of transpiled scripts (polyfill.js, image.js, i18n.js, main.js), for compatibility to old browsers.
vconsole.jsExpatvconsole.jsA mini console for debugging on mobile, will only load when explicitly invoke.
+
+
+
+
+
+ +
+ + \ No newline at end of file diff --git a/www/lang/en-US.json b/www/lang/en-US.json new file mode 100644 index 0000000..ce0b2f0 --- /dev/null +++ b/www/lang/en-US.json @@ -0,0 +1,56 @@ +{ + "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." + } +} \ No newline at end of file diff --git a/www/lang/zh-CN.json b/www/lang/zh-CN.json new file mode 100644 index 0000000..9aacf94 --- /dev/null +++ b/www/lang/zh-CN.json @@ -0,0 +1,55 @@ +{ + "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": "您可以使用以下详细信息寻求帮助。" + } +} \ No newline at end of file diff --git a/www/loader.js b/www/loader.js new file mode 100644 index 0000000..5be8fc9 --- /dev/null +++ b/www/loader.js @@ -0,0 +1,39 @@ + +/** + * Satisfy both development and old-old webView need + */ +(function() { + + var fallbacks = [ + // main scripts, which we will directly modify + 'i18n.js', 'image.js', 'main.js', + // "compatibility" script, produced with eg. typescript tsc + 'main.comp.js' + ]; + var trial_count = 0; + /** + * Try to load next "fallback" script, + * until we see the "main" variable (ie. success) + * fail if nothing is left to load. + * This is recursive. Just call once. + */ + function try_load() { + var script = document.createElement('script'); + script.addEventListener('load', function() { + if (typeof main === 'undefined') { + script.remove(); + try_load(); + } else { + console.log('Success'); + } + }); + var path = fallbacks[trial_count++]; + if (!path) throw new Error('All fallback scripts were tried'); + script.src = path; + console.log('Trying script: ' + path); + document.body.appendChild(script); + } + + try_load(); + +})(); \ No newline at end of file diff --git a/www/main.css b/www/main.css index ceb9026..3a7ac44 100644 --- a/www/main.css +++ b/www/main.css @@ -1,46 +1,297 @@ + +:root { + --font-size: 1.2em; + --span: 8px; + --span-half: calc(var(--span) / 2); + --span-double: calc(var(--span) * 2); + --border: 1px; + --border-double: calc(var(--border) * 2); + --paper-width: 384px; + --anim-time: 0.5s; + --fore-color: #111; + --back-color: #eee; + --canvas-back: #fff; + --curve: cubic-bezier(.08,.82,.17,1); + --panel-height: 20em; + --target-color: rgba(0, 255, 0, 0.2); + --notice-color: rgba(0, 0, 255, 0.2); + --notice-warning: rgba(255, 128, 0, 0.2); + --notice-error: rgba(255, 0, 0, 0.2); +} + +body.no-animation *, +body.no-animation *::before, +body.no-animation *::after { + transition-duration: 0ms !important; + transition: none; + animation-timing-function: steps(1); + animation-duration: 0ms !important; +} + body { + border: none; + background-color: var(--back-color); + color: var(--fore-color); + font-size: var(--font-size); + font-family: 'Noto Sans', 'Segoe UI', sans-serif; + overflow: auto; + margin: 1em 0; +} +h1 { + font-size: 1.5em; + font-weight: normal; + margin: var(--span-half) 0; +} +a:link, a:visited { + color: #33f; +} +a:hover, a:active { + color: #22f; +} +a { + transition: all var(--anim-time) ease-out; +} +.center { text-align: center; +} +button, input, select, textarea { + font: inherit; + color: var(--fore-color); +} +button, input[type="number"], input[type="text"], select { + margin: var(--span-half) var(--span); + border: var(--border) solid var(--fore-color); + padding: var(--span-half) var(--span); + background-color: transparent; + transition: all var(--anim-time) var(--curve); + cursor: pointer; + min-width: 6em; + display: inline-block; +} +input[type="number"], input[type="text"] { + width: 6em; + cursor: text; +} +button:hover { margin: 0; - font-size: large; - font-family: 'Noto Sans', 'Segoe UI', sans-serif; + padding: var(--span) calc(var(--span-double)); + min-width: calc(6em + var(--span-double)); } - -main.mainpage nav { +button:active { + box-shadow: 0 0 var(--span) inset var(--fore-color); +} +#notice { + min-height: var(--font-size); + background-color: var(--notice-color); + /* border: var(--border) solid var(--fore-color); */ +} +#notice.warning, #button-exit { + background-color: var(--notice-warning); +} +#notice.error { + background-color: var(--notice-error); +} +.noscript { + background-color: var(--notice-error); +} +main, header, footer { + max-width: 45em; + margin: 1em auto; display: flex; - justify-content: space-around; - flex-wrap: wrap; + flex-direction: row; + /* flex-wrap: wrap; */ } - -main.mainpage nav a { +main>.left { + flex-grow: 1; + /* position: sticky; */ + /* top: 0; */ + height: 100%; + overflow: auto; + margin: var(--span); + min-width: 12em; +} +main>.right { + flex-grow: 0; + margin: 0 auto; + height: 100%; + overflow: auto; + text-align: center; + min-width: calc(var(--paper-width) + var(--border-double) + var(--span-double)); +} +canvas#preview, canvas#control-canvas, #control-document { + border: var(--border) solid var(--fore-color); + background-color: var(--canvas-back); + width: var(--paper-width); + display: inline-block; +} +#control-document { + font: initial; + font-size: 16px; + font-family: 'Unifont'; + text-align: initial; + color: #000; +} +#control-document.normal-font { + font-family: inherit; + font-size: inherit; +} +canvas#preview { + z-index: 0; +} +canvas#control-canvas, +#control-document { + position: absolute; + opacity: 0; + transition: opacity var(--anim-time) var(--curve); + z-index: 1; +} +canvas#control-canvas:hover, +#control-document:hover { + opacity: 1; +} +canvas#control-canvas.disabled, +#control-document.disabled { + display: none; +} +p { + margin: var(--span) 0; +} +.panel { + border: var(--border) solid currentColor; + height: calc(var(--font-size) + 8px); + overflow: hidden; + /* scrollbar-width: thin; */ + transition: height var(--anim-time) var(--curve); + padding: 0 var(--span); + margin: var(--span) 0; +} +.panel::before { + float: left; +} +.panel:not(.sub)::before { content: '📌'; } +.panel.sub::before { content: '📎'; } +.panel.sub { + border-width: var(--border) 0 0 0; +} +.panel.expanded { + height: var(--panel-height); + animation: delay-scrollable var(--anim-time) steps(1) 0s 1 forwards; + /* overflow-y: scroll; */ +} +.panel.sub.expanded { + height: calc(var(--panel-height) / 2); +} +.panel>:nth-child(1), +.panel>:nth-child(1):link, +.panel>:nth-child(1):visited { + display: block; + margin: var(--span-half); + text-decoration: none; + text-align: center; + color: var(--fore-color); +} +.panel>:nth-child(1)::before { + content: '>>'; display: inline-block; - font-size: larger; + position: relative; + opacity: 0; + width: 0; + overflow: visible; + right: 2.5em; + transition: all var(--anim-time) var(--curve); +} +.panel>:nth-child(1):hover::before, +.panel>:nth-child(1):active::before { + opacity: 1; + right: 1.5em; +} +input[type="range"] { width: 10em; - padding: 0.5em 0; + vertical-align: middle; + content: attr(value); } - -a:link, a:visited { - color: blue; +@keyframes hint { + 0% { box-shadow: 0 0 var(--span-) inset transparent; } + 50% { box-shadow: 0 0 var(--span) inset var(--fore-color); } + 100% { box-shadow: 0 0 var(--span) inset transparent; } } -a:hover, a:active { - color: darkblue; +.hint { + animation: hint 3s ease-out 0.1s infinite; } - -main input[type="range"] { - width: 16em; +#hidden, .hidden { display: none; } +#error-record { + font-family: 'DejaVu Sans Mono', 'Consolas', monospace; + width: 100%; + font-size: 1rem; + overflow: auto; + white-space: pre; + height: calc(var(--panel-height) - var(--border-double) * 4); } - -#notice { - height: 2em; - border-top: 1px dotted currentColor; - border-bottom: 1px dotted currentColor; - position: sticky; - top: 0; - margin: 8px 0; +.table-wrap { + overflow-x: auto; width: 100%; } +table#jslicense-labels1 { + min-width: 40em; +} +table#jslicense-labels1 td { + padding: var(--span-half) var(--span); +} +*:target { + background-color: var(--target-color); +} +dl { + margin: var(--span) 0; + display: block; +} +hr { + border: none; + border-top: var(--border) solid var(--fore-color); +} -.reset_styles { - font-family: initial; - font-size: initial; - text-align: initial; +@keyframes delay-scrollable { + from { overflow: hidden; } + to { overflow: auto; } +} +@media (max-width: 800px) { + :root { + --panel-height: 16em; + /* --font-size: 1em; */ + } + main { + flex-direction: column; + } + main>.left { + /* height: 16em; */ + overflow: auto; + width: calc(100% - var(--span-double) - var(--border-double)); + } + main>.right { + min-width: unset; + } +} +@media (max-width: 384px) { + canvas#preview, canvas#control-canvas, #control-document { + width: calc(100% - var(--border-double)); + } +} +@media (prefers-color-scheme: dark) { + :root { + --fore-color: #eee; + --back-color: #333; + /* --canvas-back: #666; */ + } + a:link, a:visited { + color: #66f; + } + a:hover, a:active { + color: #77f; + } + canvas#preview, canvas#control-canvas, #control-document { + filter: brightness(0.5); + } +} +@font-face { + font-family: 'Unifont'; + src: local('Unifont') url('unifont.ttf') url('unifont.otf'); } diff --git a/www/main.d.ts b/www/main.d.ts deleted file mode 100644 index dd53c0a..0000000 --- a/www/main.d.ts +++ /dev/null @@ -1,34 +0,0 @@ - -declare interface i18NProto { - get(originaltext: string, language: string): string; - force(language: string): void; - recover(): void; -} - -declare var i18N: i18NProto; - -declare interface ImagePrinter { - noticeElement: HTMLParagraphElement; - thresholdInput: HTMLInputElement; - bluetoothMACInput: HTMLInputElement; - fileSelection: HTMLInputElement; - dummyImage: HTMLImageElement; - canvasPreview: HTMLCanvasElement; - previewButton: HTMLButtonElement; - printButton: HTMLButtonElement; - monoMethod: Function; -} - -declare interface DocumentPrinter { - thresholdInput: HTMLInputElement; - bluetoothMACInput: HTMLInputElement; - container: HTMLDivElement; - printButton: HTMLButtonElement; - canvasPreview: HTMLDivElement; - monoMethod: Function; -} - -declare function notice(message: string): void; -declare function imageDataColorToMonoSquare(data: ImageData, threshold: number): ImageData; -declare function imageDataColorToMonoDiamond(data: ImageData, threshold: number): ImageData; -declare function imageDataMonoToPBM(data: ImageData): Blob; diff --git a/www/main.js b/www/main.js index ef8e770..4ad9d93 100644 --- a/www/main.js +++ b/www/main.js @@ -1,181 +1,547 @@ -/// -let notice_element = document.getElementById('notice'); -function notice(message) { - notice_element.innerText = message; +'use strict'; + +/** + * In order to debug on a phone, we load vConsole + * https://www.npmjs.com/package/vconsole + * Double-tap the "Cat Printer" title to activate + */ +function debug() { + let script = document.createElement('script'); + script.src = 'vconsole.js'; + document.body.appendChild(script); + script.addEventListener('load', () => new window.VConsole()); } -let device_selection = document.getElementById('device_selection'); -let refresh_device_button = document.getElementById('refresh_device'); -let bluetooth_mac_input = document.getElementById('bluetooth_address_input'); -function switchDevice() { - bluetooth_mac_input.value = device_selection.selectedOptions[0].value; +document.getElementById('title').addEventListener('dblclick', debug); + +var hidden_area = document.getElementById('hidden'); + +const hint = (function() { + let hints = []; + let callback = (event) => { + event.stopPropagation(); + event.currentTarget.classList.remove('hint'); + event.currentTarget.removeEventListener('click', callback); + } + return function(selector) { + hints.forEach(element => element.classList.remove('hint')); + hints = document.querySelectorAll(selector); + hints.forEach(element => { + element.classList.add('hint'); + element.addEventListener('click', callback); + }); + } +})(); + +class _Notice { + element; + constructor() { + this.element = document.getElementById('notice'); + } + _message(message, ...args) { + this.element.innerText = i18n(message, ...args) || message; + } + makeLogger(class_name) { + return (message, ...args) => { + this.element.classList.value = class_name; + this._message(message, ...args); + } + } + notice = this.makeLogger('notice'); + warn = this.makeLogger('warning'); + error = this.makeLogger('error'); +} + +const Notice = new _Notice(); + +class _ErrorHandler { + recordElement; + constructor() { + this.recordElement = document.getElementById('error-record'); + } + /** + * @param {Error} error + * @param {string} output + */ + report(error, output) { + Notice.error('error-happened-please-check-error-message'); + let hidden_panel = this.recordElement.parentElement; + if (hidden_panel) hidden_panel.classList.remove('hidden'); + let div = document.createElement('div'); + div.innerText = (error.stack || (error.name + ': ' + error.message)) + '\n' + output; + this.recordElement.appendChild(div); + hint('#panel-error'); + } } -device_selection.addEventListener('input', switchDevice); -if (device_selection != null && refresh_device_button != null && bluetooth_mac_input != null) { - refresh_device_button.addEventListener('click', event => { - notice(i18N.get('Searching devices. Please wait for 5 seconds.')); - device_selection.childNodes.forEach(e => e.remove()); - let xhr = new XMLHttpRequest(); - xhr.open('GET', '/~getdevices'); - xhr.onload = () => { - for (let i of xhr.responseText.split('\n')) { - let [name, address] = i.split(','); - if (address == undefined) continue; - let option = document.createElement('option'); - option.value = address; - option.innerText = `${name} - ${address}`; - device_selection.appendChild(option); + +const ErrorHandler = new _ErrorHandler(); + +/** + * Call server API + * @param {string} path API entry, as a path + * @param {any} body JSON to send + * @param {(response: Response) => Promise} errorPreHandler + * An async function for handling the problem where `response.ok` is false. + * Omit or use `return Promise.reject()` to do final failure, or return something else to circumstance + */ +async function callApi(path, body, errorPreHandler) { + body = body || {}; + return await fetch(path, { + method: 'POST', + body: JSON.stringify(body) + }).then(async (response) => { + if (response.ok) return response.json() + else { + try { + // forgive this dirty trick + let json = response.json(); + response.json = () => json; + if (errorPreHandler) return await errorPreHandler(response); + else throw new Error('API Failure'); + } catch (error) { + ErrorHandler.report( + error, + JSON.stringify(await response.json(), undefined, 4) + ) + return Promise.reject('API Failure'); } - device_selection.selectedIndex = 0; - switchDevice(); } - xhr.send(); }); - refresh_device_button.click(); } -function imageDataColorToMonoSquare(data, threshold) { - let newdata_horizonal = new Uint8ClampedArray(data.data.length); - let newdata_vertical = new Uint8ClampedArray(data.data.length); - let darkness = 0; - for (let j = 0; j < data.height; j++) { - for (let i = 0; i < data.width; i++) { - let index = (j * data.width + i) * 4; - let [r, g, b, a] = data.data.slice(index, index + 4); - let visibility = 1 - ((r * 0.2125) + (g * 0.7154) + (b * 0.0721)) * (a / 255) / 255; - darkness += visibility; - if (darkness >= threshold) { - newdata_horizonal[index] = 0; - newdata_horizonal[index + 1] = 0; - newdata_horizonal[index + 2] = 0; - newdata_horizonal[index + 3] = 255; - darkness = 0; - } else { - newdata_horizonal[index] = 255; - newdata_horizonal[index + 1] = 255; - newdata_horizonal[index + 2] = 255; - newdata_horizonal[index + 3] = 255; +/** + * call addEventListener on all selected elements by `seletor`, + * with each element itself as `this` unless specifyed `thisArg`, + * with type `type` and a function `callback`. + * If an element have attribute `data-default` or `checked`, dispatch event immediately on it. + * You can of course assign resulting object to a variable for futher use. + */ +class EventPutter { + elements; + callback; + /** + * @param {string} selector + * @param {string} type + * @param {(event?: Event) => void} callback + * @param {any} thisArg + */ + constructor(selector, type, callback, thisArg) { + let elements = this.elements = document.querySelectorAll(selector); + if (elements.length === 0) return; + this.callback = callback; + elements.forEach(element => { + element.addEventListener(type, function(event) { + event.stopPropagation(); + event.cancelBubble = true; + callback.call(thisArg || element, event); + }); + if (element.hasAttribute('data-default') || element.checked) { + element.dispatchEvent(new Event(type)); } + }); + } +} +/** + * @param {string} selector + * @param {string} type + * @param {(event?: Event) => void} callback + * @param {any} thisArg + */ +function putEvent(selector, type, callback, thisArg) { + return new EventPutter(selector, type, callback, thisArg); +} + +class PanelController { + last; + panels; + outerPanels; + subPanels; + constructor(selector = '.panel') { + const class_expanded = 'expanded'; + const class_sub = 'sub'; + let panels = this.panels = [... document.querySelectorAll(selector)]; + let outer_panels = this.outerPanels = panels.filter(e => !e.classList.contains(class_sub)); + let sub_panels = this.subPanels = panels.filter(e => e.classList.contains(class_sub)); + const expand = (panel) => panel.classList.add(class_expanded); + const fold = (panel) => { + panel.classList.remove(class_expanded); + } + const fold_all_outer = () => outer_panels.forEach(e => fold(e)); + const fold_all_sub = () => sub_panels.forEach(e => fold(e)); + // const fold_all = () => panels.forEach(e => e.classList.remove(class_expanded)); + fold_all_outer(); + putEvent(selector + '>:nth-child(1)', 'click', event => { + event.stopPropagation(); + event.cancelBubble = true; + let current = event.currentTarget.parentElement, + last = this.last; + this.last = current; + if (!last) { + expand(current); + this.last = current; + return; + } + let is_sub = current.classList.contains(class_sub), + last_is_sub = last.classList.contains(class_sub); + if (current.classList.contains(class_expanded)) { + fold(current); + return; + } + fold_all_outer(); + if (is_sub && last_is_sub) { + fold(last); + expand(current.parentElement); + last.scrollTo(0, 0); + } else if (is_sub && !last_is_sub) { + fold_all_sub(); + expand(last); + } else if (!is_sub && last_is_sub) { + last.parentElement.scrollTo(0, 0); + } + expand(current); + }, this); + } +} + +class CanvasController { + /** @type {HTMLCanvasElement} */ + preview; + /** @type {HTMLCanvasElement} */ + canvas; + div; + isCanvas; + algorithm; + threshold; + transparentAsWhite; + previewData; + static defaultHeight = 384; + _height; + get height() { + return this._height; + } + set height(value) { + this.div.style.height = (this.canvas.height = this.preview.height = this._height = value) + 'px'; + } + constructor() { + this.preview = document.getElementById('preview'); + this.canvas = document.getElementById('control-canvas'); + this.div = document.getElementById('control-document'); + this.height = CanvasController.defaultHeight; + + putEvent('input[name="mode"]', 'change', (event) => this.enableMode(event.currentTarget.value), this); + putEvent('input[name="algo"]', 'change', (event) => this.useAlgorithm(event.currentTarget.value), this); + putEvent('#button-preview' , 'click', this.activatePreview , this); + putEvent('#canvas-expand' , 'click', this.expand , this); + putEvent('#canvas-crop' , 'click', this.crop , this); + putEvent('#insert-picture' , 'click', this.insertPicture , this); + + putEvent('#threshold', 'change', (event) => { + this.threshold = parseInt(event.currentTarget.value); + this.activatePreview(); + }, this); + putEvent('#transparent-as-white', 'change', (event) => { + this.transparentAsWhite = event.currentTarget.checked; + this.activatePreview(); + }, this); + } + enableMode(mode) { + switch (mode) { + case 'mode-document': + this.div.classList.remove('disabled'); + this.canvas.classList.add('disabled'); + this.isCanvas = false; + break; + case 'mode-canvas': + this.canvas.classList.remove('disabled'); + this.div.classList.add('disabled'); + this.isCanvas = true; + break; } - darkness = 0; - } - for (let i = 0; i < data.width; i++) { - for (let j = 0; j < data.height; j++) { - let index = (j * data.width + i) * 4; - let [r, g, b, a] = data.data.slice(index, index + 4); - let visibility = 1 - ((r * 0.2125) + (g * 0.7154) + (b * 0.0721)) * (a / 255) / 255; - darkness += visibility; - if (darkness >= threshold) { - newdata_vertical[index] = 0; - newdata_vertical[index + 1] = 0; - newdata_vertical[index + 2] = 0; - newdata_vertical[index + 3] = 255; - darkness = 0; - } else { - newdata_vertical[index] = 255; - newdata_vertical[index + 1] = 255; - newdata_vertical[index + 2] = 255; - newdata_vertical[index + 3] = 255; + } + useAlgorithm(name) { + this.algorithm = name; + this.activatePreview(); + } + expand(length = CanvasController.defaultHeight) { + this.height += length; + } + crop() {} + activatePreview() { + let preview = this.preview; + let t = this.threshold; + if (this.isCanvas) { + let canvas = this.canvas; + let w = canvas.width, h = canvas.height; + let context_c = canvas.getContext('2d'); + let context_p = preview.getContext('2d'); + let data = context_c.getImageData(0, 0, w, h); + let mono_data = new Uint8ClampedArray(w * h); + monoGrayscale(data.data, mono_data, w, h, this.transparentAsWhite); + switch (this.algorithm) { + case 'algo-direct': + monoDirect(mono_data, w, h, t); + break; + case 'algo-steinberg': + monoSteinberg(mono_data, w, h, t); + break; + case 'algo-halftone': + // monoHalftone(mono_data, w, h, t); + // Sorry, do it later + break; + case 'algo-new': + monoNew(mono_data, w, h, t); + break; + case 'algo-new-h': + monoNewH(mono_data, w, h, t); + break; + case 'algo-new-v': + monoNewV(mono_data, w, h, t); + break; + case 'algo-legacy': + monoLegacy(mono_data, w, h, t); + break; + } + let new_data = context_p.createImageData(w, h); + let p; + for (let i = 0; i < mono_data.length; i++) { + p = i * 4; + new_data.data.fill(mono_data[i], p, p + 3); + new_data.data[p + 3] = 255; } + this.previewData = mono_data; + context_p.putImageData(new_data, 0, 0); } - darkness = 0; - } - let newdata_intersection = new Uint8ClampedArray(data.data.length); - for (let i = 0; i < data.data.length; i += 4) { - if (newdata_horizonal[i] == 0 && newdata_vertical[i] == 0) { - newdata_intersection[i] = 0; - newdata_intersection[i + 1] = 0; - newdata_intersection[i + 2] = 0; - newdata_intersection[i + 3] = 255; - } else { - newdata_intersection[i] = 255; - newdata_intersection[i + 1] = 255; - newdata_intersection[i + 2] = 255; - newdata_intersection[i + 3] = 255; + } + insertPicture() { + const put_image = (url) => { + if (this.isCanvas) { + let img = document.createElement('img'); + img.src = url; + hidden_area.appendChild(img); + img.addEventListener('load', () => { + let canvas = this.canvas; + let rate = img.height / img.width; + this.height = canvas.width * rate; + let context = canvas.getContext('2d'); + context.drawImage(img, 0, 0, canvas.width, canvas.height); + this.crop(); + this.activatePreview(); + hint('#button-print, #panel-settings'); + }); + } } + let input = document.createElement('input'); + input.type = 'file'; + input.addEventListener('change', () => { + let url = URL.createObjectURL(input.files[0]); + put_image(url); + }); + hidden_area.appendChild(input); + input.click(); + } + makePbm() { + let blob = mono2pbm(this.previewData, this.preview.width, this.preview.height); + return blob; } - return new ImageData(newdata_intersection, data.width, data.height); } -function imageDataColorToMonoDiamond(data, threshold) { - // Not completed yet, but in theory will be beautiful - let newdata_lefttop_to_rightbottom = new Uint8ClampedArray(data.data.length); - let newdata_leftbottom_to_righttop = new Uint8ClampedArray(data.data.length); - let darkness = 0; - let is_odd = false; - for (let j = 0; j < data.height; j++) { - for (let i = 0; i < data.width; i++) { - let index = (j * data.width + i + (is_odd ? 1 : 0)) * 4; - let [r, g, b, a] = data.data.slice(index, index + 4); - let visibility = 1 - ((r * 0.2125) + (g * 0.7154) + (b * 0.0721)) * (a / 255) / 255; - darkness += visibility; - if (darkness >= threshold) { - newdata_lefttop_to_rightbottom[index] = 0; - newdata_lefttop_to_rightbottom[index + 1] = 0; - newdata_lefttop_to_rightbottom[index + 2] = 0; - newdata_lefttop_to_rightbottom[index + 3] = 255; - darkness = 0; - } else { - newdata_lefttop_to_rightbottom[index] = 255; - newdata_lefttop_to_rightbottom[index + 1] = 255; - newdata_lefttop_to_rightbottom[index + 2] = 255; - newdata_lefttop_to_rightbottom[index + 3] = 255; - } + +class Main { + promise; + /** @type {PanelController} */ + panelController; + /** @type {CanvasController} */ + canvasController; + deviceOptions; + /** An object containing configuration, fetched from server */ + settings; + /** @type {{ [key: string]: EventPutter }} */ + setters; + /** + * There are race conditions in initialization query/set, + * use this flag to avoid + */ + allowSet; + constructor() { + this.allowSet = false; + this.deviceOptions = document.getElementById('device-options'); + this.settings = {}; + this.setters = {}; + // window.addEventListener('unload', () => this.exit()); + this.promise = new Promise(async (resolve, reject) => { + await this.initI18n(); + this.panelController = new PanelController(); + this.canvasController = new CanvasController(); + 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('#device-options', 'input', 'printer_address'); + this.attachSetter('input[name="algo"]', 'change', 'mono_algorithm'); + this.attachSetter('#transparent-as-white', 'change', 'transparent_as_white'); + this.attachSetter('#dry-run', 'change', 'dry_run', + (checked) => checked && Notice.notice('dry-run-test-print-process-only') + ); + this.attachSetter('#no-animation', 'change', 'no_animation', + (checked) => checked ? document.body.classList.add('no-animation') + : document.body.classList.remove('no-animation') + ); + this.attachSetter('#threshold', 'change', 'threshold', + (value) => this.canvasController.threshold = value + ); + this.attachSetter('#frequency', 'change', 'frequency'); + await this.loadConfig(); + this.searchDevices(); + resolve(); + }); + } + query(key) { return this.settings[key]; } + /** Sync setting(s) to server ("set") */ + async set(body, errorPreHandler) { + if (this.allowSet) return await callApi('/set', body, errorPreHandler); + else return null; + } + /** + * Load saved config from server, and activate all setters with corresponding values in settings. + * Please do `attachSetter` on all desired elements/inputs before calling. + * After the load, will save config to server again in order to sync default values. + * Then, if permitted, every single change will sync to server instantly + */ + async loadConfig() { + this.settings = await callApi('/query'); + for (let key in this.settings) { + let value = this.settings[key]; + if (this.setters[key] === undefined) continue; + // Set the *reasonable* value + this.setters[key].elements.forEach(element => { + switch (element.type) { + case 'checkbox': + element.checked = value; + break; + case 'radio': + // Only dispatch on the selected one + if (element.value !== value) return; + element.checked = value; + break; + default: + element.value = value; + } + element.dispatchEvent(new Event('change')); + }); } - darkness = 0; - is_odd = !is_odd; - } - for (let i = 0; i < data.width; i++) { - for (let j = 0; j < data.height; j++) { - let index = (j * data.width + i + (is_odd ? 1 : 0)) * 4; - let [r, g, b, a] = data.data.slice(index, index + 4); - let visibility = 1 - ((r * 0.2125) + (g * 0.7154) + (b * 0.0721)) * (a / 255) / 255; - darkness += visibility; - if (darkness >= threshold) { - newdata_leftbottom_to_righttop[index] = 0; - newdata_leftbottom_to_righttop[index + 1] = 0; - newdata_leftbottom_to_righttop[index + 2] = 0; - newdata_leftbottom_to_righttop[index + 3] = 255; - darkness = 0; - } else { - newdata_leftbottom_to_righttop[index] = 255; - newdata_leftbottom_to_righttop[index + 1] = 255; - newdata_leftbottom_to_righttop[index + 2] = 255; - newdata_leftbottom_to_righttop[index + 3] = 255; + this.allowSet = true; + await this.set(this.settings); + } + /** + * Create an event handler and attach to selected elements, that change/reflect `settings` + * @param {string} attribute The setting to change, i.e. `this.setting[attribute] = value;` + * @param {(value: any) => any} callback Optional additinal post-procedure to call, with a *reasonable* value as parameter + */ + attachSetter(selector, type, attribute, callback) { + this.setters[attribute] = putEvent(selector, type, (event => { + event.stopPropagation(); + event.cancelBubble = true; + let input = event.currentTarget; + let value; + // Get the *reasonable* value + switch (input.type) { + case 'number': + case 'range': + value = parseFloat(input.value); break; + case 'checkbox': + value = input.checked; break; + case 'radio': + if (input.checked) value = input.value; break; + default: + value = input.value; } + this.settings[attribute] = value; + this.set({ [attribute]: value }); + return callback ? callback(value) : undefined; + }).bind(this), this); + } + async exit() { + await this.set(this.settings); + await callApi('/exit'); + window.close(); + // Browser may block the exit + Notice.notice('you-can-close-this-page-manually'); + } + /** @param {Response} response */ + async bluetoothProblemHandler(response) { + // Not complete yet, it's different across other platforms + let error_details = await response.json(); + if ( + error_details.name === 'org.bluez.Error.NotReady' || + error_details.details.indexOf('not turned on') !== -1 || + error_details.details.indexOf('WinError -2147020577') !== -1 + ) Notice.warn('please-enable-bluetooth'); + else throw new Error('Unknown Bluetooth Problem'); + return null; + } + async searchDevices() { + Notice.notice('scanning-for-devices'); + let search_result = await callApi('/devices', null, this.bluetoothProblemHandler); + if (search_result === null) return; + let devices = search_result.devices; + this.deviceOptions.childNodes.forEach(e => e.remove()); + if (devices.length === 0) { + Notice.notice('no-available-devices-found'); + hint('#device-refresh'); + return; } - darkness = 0; - is_odd = !is_odd; - } - let newdata_intersection = new Uint8ClampedArray(data.data.length); - for (let i = 0; i < data.data.length; i += 4) { - if (newdata_lefttop_to_rightbottom[i] == 0 && newdata_leftbottom_to_righttop[i] == 0) { - newdata_intersection[i] = 0; - newdata_intersection[i + 1] = 0; - newdata_intersection[i + 2] = 0; - newdata_intersection[i + 3] = 255; - } else { - newdata_intersection[i] = 255; - newdata_intersection[i + 1] = 255; - newdata_intersection[i + 2] = 255; - newdata_intersection[i + 3] = 255; + Notice.notice('found-1-available-devices', devices.length); + hint('#insert-picture'); + devices.forEach(device => { + let option = document.createElement('option'); + option.value = device.address; + option.innerText = `${device.name}-${device.address.slice(12, 14)}${device.address.slice(15)}`; + this.deviceOptions.appendChild(option); + }); + this.deviceOptions.dispatchEvent(new Event('input')); + } + async print() { + Notice.notice('printing'); + await fetch('/print', { + method: 'POST', + body: this.canvasController.makePbm() + }).then(async (response) => { + if (response.ok) Notice.notice('finished') + else { + let error_data = await response.json(); + if (/address.+not found/.test(error_data.details)) + Notice.error('please-check-if-the-printer-is-down'); + else + ErrorHandler.report( + new Error('API Failure'), + JSON.stringify(await response.json(), undefined, 4) + ) + } + }); + } + 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`) + .then(response => response.ok ? response.json() : null); + if (data !== null) { + i18n.translator.add(data); + loaded_languages.unshift(language_list[i]); + } } + console.log('Language stack:', loaded_languages); + let elements = document.querySelectorAll('*[data-i18n]'); + let i18n_data, translated_string; + elements.forEach(element => { + i18n_data = element.getAttribute('data-i18n'); + translated_string = i18n(i18n_data); + if (translated_string === i18n_data) return; + // element.innerText = translated_string; + if (element.firstChild.textContent !== translated_string) + element.firstChild.textContent = translated_string; + }); } - return new ImageData(newdata_intersection, data.width, data.height); } -function imageDataMonoToPBM(data) { - let result = new ArrayBuffer(data.data.length / 4 / 8); - let view = new DataView(result); - for (let i = 0; i < data.data.length; i += 8 * 4) { - let code = 0; - if (data.data[i + 0 * 4] == 0) code += 0b10000000; - if (data.data[i + 1 * 4] == 0) code += 0b01000000; - if (data.data[i + 2 * 4] == 0) code += 0b00100000; - if (data.data[i + 3 * 4] == 0) code += 0b00010000; - if (data.data[i + 4 * 4] == 0) code += 0b00001000; - if (data.data[i + 5 * 4] == 0) code += 0b00000100; - if (data.data[i + 6 * 4] == 0) code += 0b00000010; - if (data.data[i + 7 * 4] == 0) code += 0b00000001; - view.setInt8(i / 4 / 8, code); - } - let pbm_data = new Blob([`P4\n${data.width} ${data.height}\n`, result]); - return pbm_data; -} \ No newline at end of file + +var main = new Main(); diff --git a/www/polyfill.js b/www/polyfill.js new file mode 100644 index 0000000..19f3e46 --- /dev/null +++ b/www/polyfill.js @@ -0,0 +1,8 @@ + +/** + * Polyfill + */ +(function() { + if (!NodeList.prototype.forEach) + NodeList.prototype.forEach = Array.prototype.forEach; +})(); diff --git a/www/print-document.html b/www/print-document.html deleted file mode 100644 index e16a6ed..0000000 --- a/www/print-document.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - Print Document - - - - -
-

- -

-

Print Document

-

- - Select device: -

-

- Copy & paste document to box -
- Threshold: -

-
-
-
-
-
-

From Webpage/MS Office/WPS/LibreOffice to here, pasted data shall be formatted

-

Preview

-
-
- - - - - - - \ No newline at end of file diff --git a/www/print-document.js b/www/print-document.js deleted file mode 100644 index 2e6b8f8..0000000 --- a/www/print-document.js +++ /dev/null @@ -1,57 +0,0 @@ -/// -/// - -class DocumentPrinter { - WIDTH = 384; - thresholdInput = document.getElementById('filter_threshold'); - bluetoothMACInput = document.getElementById('bluetooth_address_input'); - container = document.getElementById('container'); - printButton = document.getElementById('print_button'); - previewButton = document.getElementById('preview_button'); - threshold = 0.2; - canvasPreview = document.getElementById('image_preview'); - monoMethod = imageDataColorToMonoSquare; - constructor() { - this.thresholdInput.onchange = event => { - this.threshold = this.thresholdInput.value; - } - this.printButton.addEventListener('click', event => { - let mac_address = this.bluetoothMACInput.value; - if (mac_address == '') { - notice(i18N.get('Please select a device')); - return; - } - if (this.canvasPreview.children.length == 0) { - notice(i18N.get('Please preview image first')); - return; - } - html2canvas(this.container).then(canvas => { - notice(i18N.get('Printing, please wait.')); - let context = canvas.getContext('2d'); - let imagedata = context.getImageData(0, 0, this.WIDTH, canvas.height); - let mono_imagedata = this.monoMethod(imagedata, this.threshold); - context.putImageData(mono_imagedata, 0, 0); - let pbm_data = imageDataMonoToPBM(mono_imagedata); - let xhr = new XMLHttpRequest(); - xhr.open('POST', '/~print?address=' + mac_address); - xhr.setRequestHeader('Content-Type', 'application-octet-stream'); - xhr.onload = () => { - notice(i18N.get(xhr.responseText)); - } - xhr.send(pbm_data); - }); - }); - this.previewButton.addEventListener('click', event => { - if (this.canvasPreview.children[0] != null) this.canvasPreview.children[0].remove(); - html2canvas(this.container).then(canvas => { - let context = canvas.getContext('2d'); - let imagedata = context.getImageData(0, 0, this.WIDTH, canvas.height); - let mono_imagedata = this.monoMethod(imagedata, this.threshold); - context.putImageData(mono_imagedata, 0, 0); - this.canvasPreview.appendChild(canvas); - }); - }) - } -} - -var document_printer = new DocumentPrinter(); diff --git a/www/print-image.html b/www/print-image.html deleted file mode 100644 index f6e703b..0000000 --- a/www/print-image.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - Print Image - - - - -
-

- -

-

Print Image

-

- - Select device: -

-

-
- Threshold:
- -

-
- - - - - - \ No newline at end of file diff --git a/www/print-image.js b/www/print-image.js deleted file mode 100644 index 4788e7c..0000000 --- a/www/print-image.js +++ /dev/null @@ -1,60 +0,0 @@ -/// -/// - -class ImagePrinter { - WIDTH = 384; - threshold = 0.6; - bluetoothMACInput = document.getElementById('bluetooth_address_input'); - thresholdInput = document.getElementById('filter_threshold'); - fileSelection = document.getElementById('file_selection'); - dummyImage = new Image(); - canvasPreview = document.getElementById('image_preview'); - previewButton = document.getElementById('preview_button'); - printButton = document.getElementById('print_button'); - preview() { - let reader = new FileReader(); - reader.onload = event1 => { - this.dummyImage.src = event1.target.result; - let height = this.WIDTH / this.dummyImage.width * this.dummyImage.height; - this.canvasPreview.width = this.WIDTH; - this.canvasPreview.height = height; - let context = this.canvasPreview.getContext('2d'); - context.drawImage(this.dummyImage, 0, 0, this.WIDTH, height); - let data = context.getImageData(0, 0, this.WIDTH, height); - context.putImageData(this.monoMethod(data, this.threshold), 0, 0); - } - reader.readAsDataURL(this.fileSelection.files[0]); - } - constructor() { - this.monoMethod = imageDataColorToMonoSquare; - this.fileSelection.addEventListener('input', this.preview.bind(this)); - this.previewButton.addEventListener('click', this.preview.bind(this)); - this.thresholdInput.onchange = event => { - this.threshold = this.thresholdInput.value; - } - this.printButton.addEventListener('click', event => { - // this.preview(); - if (this.canvasPreview.height == 0) { - notice(i18N.get('Please preview image first')); - return; - } - let mac_address = this.bluetoothMACInput.value; - if (mac_address == '') { - notice(i18N.get('Please select a device')); - return; - } - notice(i18N.get('Printing, please wait.')); - let context = this.canvasPreview.getContext('2d'); - let pbm_data = imageDataMonoToPBM(context.getImageData(0, 0, this.WIDTH, this.canvasPreview.height)); - let xhr = new XMLHttpRequest(); - xhr.open('POST', '/~print?address=' + mac_address); - xhr.setRequestHeader('Content-Type', 'application-octet-stream'); - xhr.onload = () => { - notice(i18N.get(xhr.responseText)); - } - xhr.send(pbm_data); - }); - } -} - -var image_printer = new ImagePrinter();