A new start!

This commit is contained in:
NaitLee 2022-04-06 18:13:19 +08:00
parent 01eb4c6153
commit 1de73c79d9
47 changed files with 2579 additions and 1348 deletions

166
.gitignore vendored
View File

@ -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

30
.pylintrc Normal file
View File

@ -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

17
.vscode/launch.json vendored Normal file
View File

@ -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
}
]
}

4
0-transpile.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
cd www
npx tsc --allowJs --outFile main.comp.js polyfill.js i18n.js image.js main.js
cd ..

6
COPYING Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.

118
README.md
View File

@ -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!

View File

@ -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。您也可以在命令行中运行此文件。

9
TODO Normal file
View File

@ -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
+ ...

View File

@ -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

2
build-android/1-adb-install.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/sh
adb install cat-printer*.apk

View File

@ -0,0 +1,2 @@
#!/bin/sh
p4a clean_builds && p4a clean_dists

10
build-android/3-formal-build.sh Executable file
View File

@ -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

3
build-android/8-build-push.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
./0-build-android.sh
./1-adb-install.sh

2
build-android/9-adb-logcat.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/sh
adb logcat | grep -E 'python|chromium'

111
build-android/blacklist.txt Normal file
View File

@ -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
#<sqlite3

BIN
build-android/blank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

BIN
build-android/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

98
build-android/icon.svg Normal file
View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
inkscape:export-ydpi="255.49001"
inkscape:export-xdpi="255.49001"
inkscape:export-filename="/home/nait/图片/aaaa.png"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)"
sodipodi:docname="aaaa.svg"
id="svg3"
version="1.1"
viewBox="0 0 300 300"
height="96"
width="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs7" />
<sodipodi:namedview
id="namedview5"
pagecolor="#eeeeee"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="in"
showgrid="false"
inkscape:zoom="5.6568543"
inkscape:cx="46.934212"
inkscape:cy="39.067649"
inkscape:window-width="1920"
inkscape:window-height="999"
inkscape:window-x="0"
inkscape:window-y="40"
inkscape:window-maximized="1"
inkscape:current-layer="svg3"
inkscape:snap-global="false"
units="px" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect25408"
width="300"
height="300"
x="0"
y="0"
ry="30" />
<path
style="fill:none;stroke:#101010;stroke-width:3.125px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 129.17447,162.72362 c -0.12,14.38 13.76,23.38 20.88,4.13 8.38,17.25 22.25,13.37 21.62,-5.13"
id="path857" />
<path
style="fill:#101010;fill-opacity:1;stroke:#101010;stroke-width:1.25;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 97.971924,155.06364 c -1.859329,-0.289 -4.633499,-1.13426 -6.045924,-1.84212 -7.029665,-3.52301 -11.163386,-11.67658 -10.120473,-19.96215 0.903676,-7.17941 4.27553,-12.60514 9.533554,-15.34067 6.115005,-3.18137 15.694519,-2.92063 21.600319,0.58794 5.79394,3.44211 8.94004,10.65859 8.28563,19.00543 -0.72596,9.2594 -5.83859,15.36854 -14.43623,17.25001 -1.78814,0.39131 -7.075946,0.57217 -8.816876,0.30156 z m 4.785856,-6.90573 c 0.92722,-0.49623 1.31028,-2.47068 0.70603,-3.63918 -0.49167,-0.95078 -1.86663,-1.27278 -3.04573,-0.71326 -0.819556,0.38891 -1.125521,1.02825 -1.125521,2.35191 0,0.85861 0.09491,1.11686 0.593895,1.61584 0.485846,0.48585 0.765496,0.59389 1.537166,0.59389 0.51879,0 1.11917,-0.0941 1.33416,-0.20919 z m -8.826554,-0.49733 c 2.299279,-0.68281 3.532763,-2.60527 3.532763,-5.50601 0,-2.43596 -1.106359,-4.00165 -3.284807,-4.64858 -2.182585,-0.64816 -4.281211,-0.32469 -5.684062,0.87609 -3.641992,3.11742 -1.315378,9.47948 3.49707,9.56264 0.510513,0.009 1.383079,-0.11903 1.939036,-0.28415 z m 18.617794,-3.13527 c 3.30127,-1.28241 5.47639,-5.31708 5.15803,-9.56772 -0.21891,-2.9227 -1.17891,-4.91372 -3.04749,-6.32037 -2.73438,-2.05843 -7.72392,-2.13228 -10.68031,-0.1581 -2.60847,1.74187 -4.055476,5.28086 -3.47178,8.49098 0.70013,3.85038 2.77955,6.66135 5.73815,7.75685 1.48641,0.55039 4.626,0.44995 6.3034,-0.20165 z"
id="path1376"
inkscape:export-filename="/home/nait/图片/path1376.png"
inkscape:export-xdpi="300.00012"
inkscape:export-ydpi="300.00012" />
<path
style="fill:#101010;fill-opacity:1;stroke:#101010;stroke-width:1.25;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 202.99373,155.06364 c 1.85933,-0.289 4.63349,-1.13426 6.04592,-1.84212 7.02967,-3.52301 11.16339,-11.67658 10.12047,-19.96215 -0.90367,-7.17941 -4.27553,-12.60514 -9.53355,-15.34067 -6.11501,-3.18137 -15.69453,-2.92063 -21.60032,0.58794 -5.79394,3.44211 -8.94005,10.65859 -8.28563,19.00543 0.72596,9.2594 5.83858,15.36854 14.43623,17.25001 1.78814,0.39131 7.07595,0.57217 8.81688,0.30156 z m -4.78586,-6.90573 c -0.92723,-0.49623 -1.31029,-2.47068 -0.70603,-3.63918 0.49167,-0.95078 1.86663,-1.27278 3.04573,-0.71326 0.81956,0.38891 1.12552,1.02825 1.12552,2.35191 0,0.85861 -0.0949,1.11686 -0.5939,1.61584 -0.48584,0.48585 -0.7655,0.59389 -1.53716,0.59389 -0.5188,0 -1.11917,-0.0941 -1.33416,-0.20919 z m 8.82655,-0.49733 c -2.29927,-0.68281 -3.53276,-2.60527 -3.53276,-5.50601 0,-2.43596 1.10636,-4.00165 3.28481,-4.64858 2.18258,-0.64816 4.28121,-0.32469 5.68406,0.87609 3.64199,3.11742 1.31538,9.47948 -3.49707,9.56264 -0.51051,0.009 -1.38308,-0.11903 -1.93904,-0.28415 z m -18.61779,-3.13527 c -3.30128,-1.28241 -5.4764,-5.31708 -5.15803,-9.56772 0.2189,-2.9227 1.17891,-4.91372 3.04748,-6.32037 2.73439,-2.05843 7.72393,-2.13228 10.68032,-0.1581 2.60847,1.74187 4.05548,5.28086 3.47178,8.49098 -0.70013,3.85038 -2.77955,6.66135 -5.73816,7.75686 -1.48641,0.55038 -4.626,0.44994 -6.30339,-0.20166 z"
id="path1569" />
<path
id="path1570"
fill="none"
stroke="#000000"
stroke-width="1"
d="m 59.000064,91.161504 c 11.999998,-34 48.499996,-50.5 76.499996,-7 m 30,0 c 17.5,-30.5 60.5,-38.5 75.5,5.5"
style="stroke:#101010;stroke-width:6;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#101010;fill-opacity:1;stroke:#101010;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 175.11938,86.255381 c 13.12597,-23.368892 45.37837,-29.498438 56.62922,4.214061 z"
id="path1754"
sodipodi:nodetypes="ccc" />
<path
style="fill:#101010;fill-opacity:1;stroke:#101010;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 69.03556,92.038425 c 9.000668,-26.050567 36.3777,-38.692754 57.37925,-5.363351 z"
id="path1635"
sodipodi:nodetypes="ccc" />
<g
id="g24602"
transform="translate(22.00006,20.818541)">
<rect
style="fill:none;fill-opacity:1;stroke:#101010;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect22570"
width="206.15567"
height="7.2529359"
x="24.922165"
y="172.40291" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#cccccc;stroke-width:0.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect22674"
width="180.68272"
height="48.131855"
x="37.658638"
y="175.78719" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

4
build-common/0-bundle-all.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
python3 bundle.py $1
python3 bundle.py -w $1
python3 bundle.py -b $1

122
build-common/bundle.py Normal file
View File

@ -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()

5
build-common/start.bat Normal file
View File

@ -0,0 +1,5 @@
@echo off
color f0
title Cat Printer - Console
cd cat-printer
python server.py

57
dev-diary.txt Normal file
View File

@ -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 <input type="file"> 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!

115
development.md Normal file
View File

@ -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<version>.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 `<canvas>` `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<tab><enter>`
- `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`

5
main.py Normal file
View File

@ -0,0 +1,5 @@
'For python-for-android entry point'
from server import serve
serve()

View File

@ -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 @@ crc8_table = [
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 @@ class PBMData():
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'
standard_pbm_data_length_per_line = int(standard_width / 8) # 48
pbm_data_per_line = int(standard_width / 8) # 48
'Constant, determined by standard width & PBM data format'
characteristic = '0000ae01-0000-1000-8000-00805f9b34fb'
'The BLE characteristic, a constant of the printer'
def __init__(self, mtu=200, feed_after=128):
self.mtu = mtu
self.feed_after = feed_after
mtu = 200
def _reverse_binary(self, value):
return int(f"{bin(value)[2:]:0>8}"[::-1], 2)
def __init__(self):
pass
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)
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 @@ class PrinterDriver():
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)
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()
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__':
len_argv = len(sys.argv)
printer = PrinterDriver()
loop = asyncio.get_event_loop()
if len_argv == 1:
print(
'Usage: %s <xx:xx:xx:xx:xx:xx> [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]))
asyncio.run(main())

431
server.py
View File

@ -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
mimetypes = {
'html': 'text/html',
'txt': 'text/plain',
'js': 'text/javascript',
'css': 'text/css'
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)
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
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', mime('json'))
self.end_headers()
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(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:
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)
self.send_response(200)
self.send_header('Content-Type', 'application/ipp')
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.end_headers()
self.wfile.write(b'')
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)
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
""" Handle requests in a separate thread. """
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()

28
www/_load.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<!-- For replacing python-for-android webview bootstrap _load.html -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Webview Loading</title>
<style>
:root {
--fore-color: #111;
--back-color: #eee;
}
body {
background-color: var(--back-color);
color: var(--fore-color);
}
@media (prefers-color-scheme: dark) {
:root {
--fore-color: #eee;
--back-color: #333;
}
}
</style>
</head>
<body>
</body>
</html>

View File

@ -1,91 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Print</title>
<link rel="stylesheet" href="main.css" />
<link rel="stylesheet" href="skin.css" />
</head>
<body>
<main>
<p id="notice">
<noscript>Javascript should be enabled</noscript>
</p>
<h2>Custom Print</h2>
<p>
<input style="display: none;" type="text" id="bluetooth_address_input" value="" />
<span>Select device:</span><select id="device_selection"></select><button id="refresh_device">Refresh</button>
</p>
<p>
<span>
<span>Operate directly on the box canvas</span>
<button id="preview_button">Preview</button>
<button id="print_button">Print</button>
<br />
<span>Canvas Height:</span>
<input type="number" value="400" min="0" max="114514" step="50" id="canvas_height" />
<br />
<span>Threshold:</span>
<input type="range" min="0" max="2.0" step="0.05" value="0.6" id="filter_threshold" />
</span>
<br />
<span>
<span>Insert:</span>
<button id="action_insert_text">Text</button>
<button id="action_insert_image">Image</button>
<button id="action_insert_qr">QRCode</button>
<br />
<span>Action:</span>
<button id="action_make_bold">Bold</button>
<button id="action_make_italic">Italic</button>
<button id="action_make_underline">Underline</button>
<button id="action_switch_paint">Switch Paint</button>
<button id="action_delete">Delete</button>
</span>
</p>
<div style="margin: auto; width: 386px;">
<canvas id="work_canvas" style="border: 1px solid currentColor; width: 384px; min-height: 3em;" width="384" height="384"></canvas>
</div>
<p>
<span>Preview</span><br />
<canvas id="image_preview" style="width: 384px;" width="384" height="0"></canvas>
</p>
</main>
<textarea id="i18-N" style="display: none;">
[zh-CN]
Custom Print=自定义打印
Javascript should be enabled=需启用 Javascript
Select device:=选择设备:
Refresh=刷新
Operate directly on the box canvas=直接在框内画布操作
Preview=预览
Print=打印
Threshold:=阈值:
Please select a device=请选择一个设备
Printing, please wait.=打印中,请稍候。
Please preview image first=请先预览图像
Searching devices. Please wait for 5 seconds.=正在查找设备。请等候 5 秒。
OK=完成
Insert:=插入:
Text=文本
Image=图像
QRCode=二维码
Double click to edit=双击以修改
Action:=操作:
Bold=粗体
Italic=斜体
Underline=下划线
Delete=删除
Switch Paint=切换绘画
Canvas Height:=画布高度:
Content of QRCode:=二维码的内容:
</textarea>
<script src="i18n.js"></script>
<script src="qrcode.min.js"></script>
<script src="fabric.min.js"></script>
<script src="main.js"></script>
<script src="custom-print.js"></script>
</body>
</html>

View File

@ -1,141 +0,0 @@
///<reference path="main.js" />
///<reference path="main.d.ts" />
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();

View File

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help</title>
<link rel="stylesheet" href="../skin.css" />
</head>
<body>
<main>
<h1>Help</h1>
<p>Here is some useful information.</p>
<h2>Note</h2>
<ul>
<li>Preview (and printed) image is mono-colored. Only black and white.</li>
<li>Threshold is the "darkness" required for image pixels to turning black. The higher it is, the image is brighter.</li>
</ul>
<hr />
<h2>Function</h2>
<h3>Print Image</h3>
<p>You can print an image to cat printer in that page.</p>
<p>Select a photo, click preview, check the preview and adjust threshold.</p>
<p>When satisfied, select printer device and print!</p>
<h3>Print Document</h3>
<p>Simply copy-paste your document content from office software to that page, the format is preserved and the result can be previewed.</p>
<p>Image can't be there because of security policy. Please use "Custom Print" if necessory.</p>
<h3>Custom Print</h3>
<p>You can freely arrange the content of a canvas then print it. Insert/edit text, image, or QRCode.</p>
<hr />
</main>
</body>
</html>

View File

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>帮助</title>
<link rel="stylesheet" href="../skin.css" />
</head>
<body>
<main>
<h1>帮助</h1>
<p>这里有一些有用的信息。</p>
<h2>注记</h2>
<ul>
<li>预览与打印出的图像是单色的。只有黑色和白色。</li>
<li>阈值是像素变为黑色所需的“黑度”。它的值越高,图像越亮。</li>
</ul>
<hr />
<h2>功能</h2>
<h3>打印图片</h3>
<p>您可以在此页将图像打印至猫咪打印机。</p>
<p>选择图片,点击预览,检查预览并调整阈值。</p>
<p>感觉满意后,选择打印机设备并打印!</p>
<h3>打印文档</h3>
<p>只需把文档从办公软件复制粘贴到此页,文档格式被保留且可以预览。</p>
<p>由于安全策略,无法粘贴图像。如果需要请使用“自定义打印”。</p>
<h3>自定义打印</h3>
<p>您可以自由排布画布上的内容并打印。插入/编辑文字、图像或二维码。</p>
<hr />
</main>
</body>
</html>

View File

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help</title>
<link rel="stylesheet" href="../skin.css" />
</head>
<body>
<h1>Help Documents</h1>
<nav>
<a href="help.en-US.html">English</a>
<a href="help.zh-CN.html">简体中文</a>
</nav>
<main></main>
</body>
</html>

BIN
www/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

211
www/image.js Normal file
View File

@ -0,0 +1,211 @@
/**
* Convert colored image to grayscale.
* @param {Uint8ClampedArray} image_data `data` property of an ImageData instance,
* i.e. `canvas.getContext('2d').getImageData(...).data`
* @param {Uint8ClampedArray} mono_data an `Uint8ClampedArray` that have the size `w * h`
* i.e. `image_data.length / 4`
* The result data will be here, as a 8-bit grayscale image data.
* @param {number} w width of image
* @param {number} h height of image
* @param {boolean} transparencyAsWhite whether render opacity as white rather than black
*/
function monoGrayscale(image_data, mono_data, w, h, transparencyAsWhite) {
let p, q, r, g, b, a, m;
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
p = j * w + i;
q = p * 4;
[r, g, b, a] = image_data.slice(q, q + 4);
a /= 255;
if (a < 1 && transparencyAsWhite) {
a = 1 - a;
r += (255 - r) * a;
g += (255 - g) * a;
b += (255 - b) * a;
}
else { r *= a; g *= a; b *= a; }
m = Math.floor(r * 0.2125 + g * 0.7154 + b * 0.0721);
mono_data[p] = m;
}
}
}
/**
* The most simple monochrome algorithm, any value bigger than threshold is white, otherwise black.
* @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 monoDirect(data, w, h, t) {
let p;
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
p = j * w + i;
data[p] = data[p] > 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;
}

View File

@ -2,30 +2,133 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Printer Center</title>
<title>Cat Printer</title>
<link rel="stylesheet" href="main.css" />
<link rel="stylesheet" href="skin.css" />
<link rel="icon" href="icon.png" />
</head>
<body>
<main class="mainpage">
<h1>Printer Center</h1>
<nav>
<a href="print-image.html" target="_blank">Print Image</a>
<a href="print-document.html" target="_blank">Print Document</a>
<a href="custom-print.html" target="_blank">Custom Print</a>
<a href="help/index.html" target="_blank">Help</a>
</nav>
<main>
<div class="left">
<h1 id="title" data-i18n="cat-printer">Cat Printer</h1>
<div id="notice">
<noscript>
<span class="noscript">Please enable JavaScript!</span>
</noscript>
</div>
<div class="panel" id="panel-print">
<a href="#" data-i18n="print" data-default>Print</a>
<label for="device-options" data-i18n="device-">Device:</label>
<select id="device-options">
<!-- Initialized by script -->
</select>
<button id="device-refresh" data-i18n="refresh">Refresh</button>
<br />
<label data-i18n="mode-">Mode:</label>
<label>
<input type="radio" name="mode" value="mode-canvas" checked />
<span data-i18n="canvas">Canvas</span>
</label>
<!-- <label>
<input type="radio" name="mode" value="mode-document" />
<span data-i18n="document">Document</span>
</label><br /> -->
<button id="insert-picture" data-i18n="insert-picture">Insert Picture</button><br />
</div>
<div class="panel expanded" id="panel-help">
<a href="#" data-i18n="help">Help</a>
<div>
<p data-i18n="coming-soon-">Coming Soon...</p>
<p>
<!-- LibreJS doesn't work with dynamically inserted script tag. Going to complain -->
<a href="jslicense.html" data-jslicense="1"
data-i18n="javascript-license-information">JavaScript License Information</a>
</p>
</div>
</div>
<div class="panel" id="panel-settings">
<a href="#" data-i18n="settings">Settings</a>
<div class="sub panel">
<a href="#" data-i18n="image">Image</a>
<label data-i18n="monochrome-algorithm-">Monochrome Algorithm:</label><br />
<label>
<input type="radio" name="algo" value="algo-direct" />
<span data-i18n="direct">Direct</span>
</label>
<label>
<input type="radio" name="algo" value="algo-steinberg" checked />
<span data-i18n="floyd-steinberg">Floyd Steinberg</span>
</label><br />
<!-- <label>
<input type="radio" name="algo" value="algo-halftone" />
<span data-i18n="halftone">Halftone</span>
</label> -->
<label>
<input type="radio" name="algo" value="algo-new-h" />
<span data-i18n="wave">Wave</span>
</label>
<!-- <label>
<input type="radio" name="algo" value="algo-new-v" />
<span data-i18n="fall">Fall</span>
</label> -->
<label>
<input type="radio" name="algo" value="algo-legacy" />
<span data-i18n="legacy">Legacy</span>
</label><br />
<label for="threshold" data-i18n="threshold-">Threshold:</label>
<input type="range" min="0" max="255" value="128" id="threshold" step="8" data-default />
<br />
<input type="checkbox" name="transparent-as-white" id="transparent-as-white" checked />
<label for="transparent-as-white" data-i18n="transparent-as-white">Transparent as White</label>
</div>
<div class="sub panel">
<a href="#" data-i18n="printer">Printer</a>
<label for="scan-time" data-i18n="scan-time-">Scan Time:</label>
<input type="number" name="scan-time" id="scan-time" min="1" max="10" value="3" />
<span data-i18n="-seconds">seconds</span>
<br />
<label for="frequency" data-i18n="transmission-speed-">Data Speed:</label>
<select id="frequency">
<option value="1.0" data-i18n="low">Low</option>
<option value="0.8" data-i18n="moderate" selected>Moderate</option>
<option value="0.6" data-i18n="high">High</option>
</select>
<br />
<input type="checkbox" name="dry-run" id="dry-run" />
<label for="dry-run" data-i18n="dry-run">Dry Run</label>
</div>
<div class="sub panel">
<a href="#" data-i18n="system">System</a>
<input type="checkbox" name="no-animation" id="no-animation" />
<label for="no-animation" data-i18n="disable-page-animation">Disable Page Animation</label>
</div>
<div class="center">
<button id="button-exit" data-i18n="exit">Exit</button>
</div>
</div>
<div class="panel hidden" id="panel-error">
<a href="#" data-i18n="error-message">Error Message</a>
<p data-i18n="you-can-seek-for-help-with-detailed-info-below">You can seek for help with detailed info below.</p>
<div id="error-record"></div>
</div>
<div class="center">
<!-- <button id="button-preview" data-i18n="preview">Preview</button> -->
<button id="button-print" data-i18n="print">Print</button>
</div>
</div>
<div class="right">
<canvas id="control-canvas" class="disabled" width="384" height="384"></canvas>
<div id="control-document" class="disabled" contenteditable="true"></div>
<canvas id="preview" width="384" height="384"></canvas>
<div class="center">
<!-- <button id="canvas-expand" data-i18n="expand">Expand</button>
<button id="canvas-crop" data-i18n="crop">Crop</button> -->
</div>
</div>
</main>
<textarea id="i18-N" style="display: none;">
[zh-CN]
Printer Center=打印机中心
Print Image=打印图片
Print Document=打印文档
Custom Print=自定义打印
Help=帮助
</textarea>
<script src="i18n.js"></script>
<div id="hidden" class="hidden">
<!-- Hidden area for putting elements -->
</div>
<script src="loader.js"></script>
</body>
</html>

92
www/jslicense.html Normal file
View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JavaScript License Information</title>
<link rel="stylesheet" href="main.css" />
<link rel="icon" href="icon.png" />
</head>
<body>
<main>
<div>
<h1>JavaScript License Information</h1>
<p>You can see all JavaScript used along with this application are Free Software.<sup><a href="#footer-1">[1]</sup></a></p>
<p class="center">
<a href="/">Go Back</a>
</p>
<div class="table-wrap">
<table id="jslicense-labels1">
<thead>
<tr>
<td>Resource</td>
<td>License</td>
<td>Source</td>
<td>Description</td>
</tr>
</thead>
<tbody>
<tr>
<td><a href="loader.js">loader.js</a></td>
<td><a href="http://creativecommons.org/publicdomain/zero/1.0/legalcode">CC0-1.0-only</a></td>
<td><a href="loader.js">loader.js</a></td>
<td>For dynamically loading other scripts, and fallback if there are problems.</td>
</tr>
<tr>
<td><a href="image.js">image.js</a></td>
<td><a href="http://creativecommons.org/publicdomain/zero/1.0/legalcode">CC0-1.0-only</a></td>
<td><a href="image.js">image.js</a></td>
<td>Contains functions for image manipulation and public algorithms of image monochrome filters.</td>
</tr>
<tr>
<td><a href="i18n.js">i18n.js</a></td>
<td><a href="http://www.jclark.com/xml/copying.txt">Expat</a></td>
<td><a href="https://github.com/roddeh/i18njs">i18n.js</a></td>
<td>For internationalization (language support)</td>
</tr>
<tr>
<td><a href="main.js">main.js</a></td>
<td><a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU-GPL-3.0-or-later</a></td>
<td><a href="main.js">main.js</a></td>
<td>The main script for Cat-Printer</td>
</tr>
<tr>
<td><a href="polyfill.js">polyfill.js</a></td>
<td><a href="http://creativecommons.org/publicdomain/zero/1.0/legalcode">CC0-1.0-only</a></td>
<td><a href="polyfill.js">polyfill.js</a></td>
<td>Put features that are not supported by old browsers.</td>
</tr>
<tr>
<td><a href="main.comp.js">main.comp.js</a></td>
<td><a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU-GPL-3.0-or-later</a></td>
<td><a href="main.js">main.js</a></td>
<td>A bundle of transpiled scripts (polyfill.js, image.js, i18n.js, main.js), for compatibility to old browsers.</td>
</tr>
<tr>
<td><a href="vconsole.js">vconsole.js</a></td>
<td><a href="http://opensource.org/licenses/MIT">Expat</a></td>
<td><a href="https://github.com/Tencent/vConsole">vconsole.js</a></td>
<td>A mini console for debugging on mobile, will only load when explicitly invoke.</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<hr />
<footer>
<dl id="footer-1">
<dt>
<span>[1]</span>
<a href="https://www.gnu.org/philosophy/free-sw.html">Free Software</a>
</dt>
<dd>
<span>Software that respects your freedom.</span>
</dd>
</dl>
</footer>
</body>
</html>

56
www/lang/en-US.json Normal file
View File

@ -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."
}
}

55
www/lang/zh-CN.json Normal file
View File

@ -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": "您可以使用以下详细信息寻求帮助。"
}
}

39
www/loader.js Normal file
View File

@ -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();
})();

View File

@ -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 {
text-align: center;
margin: 0;
font-size: large;
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;
}
main.mainpage nav {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
h1 {
font-size: 1.5em;
font-weight: normal;
margin: var(--span-half) 0;
}
main.mainpage nav a {
display: inline-block;
font-size: larger;
width: 10em;
padding: 0.5em 0;
}
a:link, a:visited {
color: blue;
color: #33f;
}
a:hover, a:active {
color: darkblue;
color: #22f;
}
main input[type="range"] {
width: 16em;
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;
padding: var(--span) calc(var(--span-double));
min-width: calc(6em + var(--span-double));
}
button:active {
box-shadow: 0 0 var(--span) inset var(--fore-color);
}
#notice {
height: 2em;
border-top: 1px dotted currentColor;
border-bottom: 1px dotted currentColor;
position: sticky;
top: 0;
margin: 8px 0;
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;
flex-direction: row;
/* flex-wrap: wrap; */
}
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;
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;
vertical-align: middle;
content: attr(value);
}
@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; }
}
.hint {
animation: hint 3s ease-out 0.1s infinite;
}
#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);
}
.table-wrap {
overflow-x: auto;
width: 100%;
}
.reset_styles {
font-family: initial;
font-size: initial;
text-align: initial;
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);
}
@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');
}

34
www/main.d.ts vendored
View File

@ -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;

View File

@ -1,181 +1,547 @@
///<reference path="main.d.ts" />
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;
}
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);
}
device_selection.selectedIndex = 0;
switchDevice();
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');
}
}
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<any>} 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');
}
}
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;
}
}
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);
}
}
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');
});
}
}
darkness = 0;
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();
}
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;
makePbm() {
let blob = mono2pbm(this.previewData, this.preview.width, this.preview.height);
return blob;
}
}
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'));
});
}
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;
}
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]);
}
}
darkness = 0;
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;
});
}
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;
}
}
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;
}
}
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;
}
}
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;
}
}
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;
}
var main = new Main();

8
www/polyfill.js Normal file
View File

@ -0,0 +1,8 @@
/**
* Polyfill
*/
(function() {
if (!NodeList.prototype.forEach)
NodeList.prototype.forEach = Array.prototype.forEach;
})();

View File

@ -1,57 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Print Document</title>
<link rel="stylesheet" href="main.css" />
<link rel="stylesheet" href="skin.css" />
</head>
<body>
<main>
<p id="notice">
<noscript>Javascript should be enabled</noscript>
</p>
<h2>Print Document</h2>
<p>
<input style="display: none;" type="text" id="bluetooth_address_input" value="" />
<span>Select device:</span><select id="device_selection"></select><button id="refresh_device">Refresh</button>
</p>
<p>
<span>Copy & paste document to box</span><button id="preview_button">Preview</button>
<button id="print_button">Print</button><br />
<span>Threshold:</span><input type="range" min="0" max="2" step="0.05" value="0.2" id="filter_threshold" />
</p>
<div style="margin: auto; border: 1px solid black; width: 384px; min-height: 3em;">
<div id="container" style="padding: 0.5em 0;">
<div class="reset_styles" contenteditable="true" style="width: 384px; min-height: 3em;"></div>
</div>
</div>
<p><i>From Webpage/MS Office/WPS/LibreOffice to here, pasted data shall be formatted</i></p>
<p>Preview</p>
<div id="image_preview" style="width: 384px; margin: auto;"></div>
</main>
<textarea id="i18-N" style="display: none;">
[zh-CN]
Print Document=打印文档
Javascript should be enabled=需启用 Javascript
Select device:=选择设备:
Refresh=刷新
Copy & paste document to box=复制粘贴文档至框内
Preview=预览
Print=打印
Threshold:=阈值:
From Webpage/MS Office/WPS/LibreOffice to here, pasted data shall be formatted=从网页/MS Office/WPS/LibreOffice 到这里,粘贴的数据格式会保留
Please select a device=请选择一个设备
Printing, please wait.=打印中,请稍候。
Please preview image first=请先预览图像
Searching devices. Please wait for 5 seconds.=正在查找设备。请等候 5 秒。
OK=完成
</textarea>
<script src="i18n.js"></script>
<script src="html2canvas.min.js"></script>
<script src="main.js"></script>
<script src="print-document.js"></script>
</body>
</html>

View File

@ -1,57 +0,0 @@
///<reference path="main.js" />
///<reference path="main.d.ts" />
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();

View File

@ -1,45 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Print Image</title>
<link rel="stylesheet" href="main.css" />
<link rel="stylesheet" href="skin.css" />
</head>
<body>
<main>
<p id="notice">
<noscript>Javascript should be enabled</noscript>
</p>
<h2>Print Image</h2>
<p>
<input style="display: none;" type="text" id="bluetooth_address_input" value="" />
<span>Select device:</span><select id="device_selection"></select><button id="refresh_device">Refresh</button>
</p>
<p>
<input type="file" id="file_selection" /><button id="preview_button">Preview</button><button id="print_button">Print</button><br />
<span>Threshold:</span><input type="range" min="0" max="2" step="0.05" value="0.6" id="filter_threshold" /><br />
<canvas id="image_preview" width="384" height="0"></canvas>
</p>
</main>
<textarea id="i18-N" style="display: none;">
[zh-CN]
Print Image=打印图片
Javascript should be enabled=需启用 Javascript
Select device:=选择设备:
Refresh=刷新
Preview=预览
Print=打印
Threshold:=阈值:
Please preview image first=请先预览图像
Searching devices. Please wait for 5 seconds.=正在查找设备。请等候 5 秒。
OK=完成
Printing, please wait.=打印中,请稍候。
</textarea>
<script src="i18n.js"></script>
<script src="main.js"></script>
<script src="print-image.js"></script>
</body>
</html>

View File

@ -1,60 +0,0 @@
///<reference path="main.js" />
///<reference path="main.d.ts" />
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();