Major code clean up; new features:

Test unknown devices (scan everything)
Rework of Web FE Event & Dispatch
Fix major performance problem
Better image processing algorithm
New Halftone-like dither algo., namingly "Pattern"
Some fixes to CSS
Fix potential misc problems by manual testing
This commit is contained in:
NaitLee 2022-07-15 02:51:44 +08:00
parent 502a572183
commit 500971ae95
25 changed files with 709 additions and 401 deletions

2
.gitignore vendored
View File

@ -7,6 +7,8 @@ www/main.comp.js
www/vconsole.js www/vconsole.js
# https://github.com/delight-im/Android-AdvancedWebView # https://github.com/delight-im/Android-AdvancedWebView
build-android/advancedwebview build-android/advancedwebview
# cd wasm && npm install
wasm/node_modules
# python bytecode # python bytecode
*.pyc *.pyc
# releases # releases

View File

@ -1,4 +1,5 @@
{ {
"js/ts.implicitProjectConfig.strictNullChecks": false, "js/ts.implicitProjectConfig.strictNullChecks": false,
"js/ts.implicitProjectConfig.checkJs": false "js/ts.implicitProjectConfig.checkJs": false,
"js/ts.implicitProjectConfig.experimentalDecorators": true
} }

View File

@ -4,17 +4,14 @@ English | [Deutsch](./readme.i18n/README.de_DE.md) | [简体中文](./readme.i18
🐱🖨 A project that provides support to some Bluetooth "Cat Printer" models, on *many* platforms! 🐱🖨 A project that provides support to some Bluetooth "Cat Printer" models, on *many* platforms!
[![cat-printer-poster](https://repository-images.githubusercontent.com/403563361/ad018f6e-3a6e-4028-84b2-205f7d35c22b)](https://repository-images.githubusercontent.com/403563361/ad018f6e-3a6e-4028-84b2-205f7d35c22b) [![cat-printer-poster](https://repository-images.githubusercontent.com/403563361/93e32942-856c-4552-a8b0-b03c0976a3a7)](https://repository-images.githubusercontent.com/403563361/93e32942-856c-4552-a8b0-b03c0976a3a7)
## Models ## Models
Currently: Known to support: `GB0X, GT01, YT01` (`X` represents any digit)
| | | You can test other models with the Web UI, in `Settings -> Test Unknown Device`
|----|----| It may work!
| Known to support | `GB0X, GT01, YT01` |
\* `X` represents any digit
## Features ## Features
@ -63,11 +60,15 @@ Get the newest apk release and install, then well done!
It may ask for background location permission, you can deny it safely. It may ask for background location permission, you can deny it safely.
(Foreground) Location permission is required for scanning Bluetooth devices in newer Android system. (Foreground) Location permission is required for scanning Bluetooth devices in newer Android system.
Recommend to set scan time to 1 second.
### Windows ### Windows
Get the newest release archive with "windows" in the file name, Get the newest release archive with "windows" in the file name,
extract to somewhere and run `start.bat` extract to somewhere and run `start.bat`
Windows typically needs longer scan time. Defaults to 4 seconds, try to find your case.
### GNU/Linux ### GNU/Linux
You can get the "pure" release, extract it, fire a terminal inside and run: You can get the "pure" release, extract it, fire a terminal inside and run:
@ -75,6 +76,8 @@ You can get the "pure" release, extract it, fire a terminal inside and run:
python3 server.py python3 server.py
``` ```
Recommend to set scan time to 2 seconds.
On Arch Linux based distros you may first install `bluez`, as it's often missing On Arch Linux based distros you may first install `bluez`, as it's often missing
```bash ```bash
sudo pacman -S bluez bluez-utils sudo pacman -S bluez bluez-utils

View File

@ -184,3 +184,15 @@ Tried to "fix" it, used at least 4 hours, finally found it's a matter of didn't
So the Internet JavaScript memes are damned true. So the Internet JavaScript memes are damned true.
https://programmerhumor.io/javascript-memes/why-is-it-like-this-2/ https://programmerhumor.io/javascript-memes/why-is-it-like-this-2/
https://programmerhumor.io/javascript-memes/sorry-dad-_-2/ https://programmerhumor.io/javascript-memes/sorry-dad-_-2/
14th
... How silly is the above approach. This time I simply changed to Uint32Array. That became much more trivial.
So, I've re-written the image processing "lib". I wanted to go for WebAssembly (with AssemblyScript), so made it separate (in dir `wasm`).
After finish, it really worked -- but it's ~100% slower than the equivalent JavaScript (`asc` versus `tsc`)
And that may involve unacceptable change to scripting structure (ESModule etc.), thus Wasm was given up.
But hey, in this rewrite some algorithm practial overhead was removed, thus much more efficient! Enjoy the blazing speed!
In main.js the event handler was reworked too. No more double event dispatches.
Thanks to this, another image processing performance problem is fixed.

View File

@ -270,7 +270,7 @@ class PrinterDriver(Commander):
model: Model = None model: Model = None
'The printer model' 'The printer model'
scan_timeout: float = 4.0 scan_time: float = 4.0
connection_timeout : float = 5.0 connection_timeout : float = 5.0
@ -351,15 +351,19 @@ class PrinterDriver(Commander):
self.device.start_notify(self.rx_characteristic, notify) self.device.start_notify(self.rx_characteristic, notify)
) )
def scan(self, identifier: str=None, *, use_result=False): def scan(self, identifier: str=None, *, use_result=False, everything=False):
''' Scan for supported devices, optionally filter with `identifier`, ''' Scan for supported devices, optionally filter with `identifier`,
which can be device model (bluetooth name), and optionally MAC address, after a comma. which can be device model (bluetooth name), and optionally MAC address, after a comma.
If `use_result` is True, connect to the first available device to driver instantly. If `use_result` is True, connect to the first available device to driver instantly.
If `everything` is True, return all bluetooth devices found.
Note: MAC address doesn't work on Apple MacOS. In place with it, Note: MAC address doesn't work on Apple MacOS. In place with it,
You need an UUID of BLE device dynamically given by MacOS. You need an UUID of BLE device dynamically given by MacOS.
''' '''
if self.fake: if self.fake:
return return []
if everything:
devices = self.loop(BleakScanner.discover(self.scan_time))
return devices
if identifier: if identifier:
if identifier.find(',') != -1: if identifier.find(',') != -1:
name, address = identifier.split(',') name, address = identifier.split(',')
@ -370,12 +374,12 @@ class PrinterDriver(Commander):
if use_result: if use_result:
self.connect(name, address) self.connect(name, address)
return [BLEDevice(address, name)] return [BLEDevice(address, name)]
elif (identifier not in Models and if (identifier not in Models and
identifier[2::3] != ':::::' and len(identifier.replace('-', '')) != 32): identifier[2::3] != ':::::' and len(identifier.replace('-', '')) != 32):
error('model-0-is-not-supported-yet', identifier, exception=PrinterError) error('model-0-is-not-supported-yet', identifier, exception=PrinterError)
# scanner = BleakScanner() # scanner = BleakScanner()
devices = [x for x in self.loop( devices = [x for x in self.loop(
BleakScanner.discover(self.scan_timeout) BleakScanner.discover(self.scan_time)
) if x.name in Models] ) if x.name in Models]
if identifier: if identifier:
if identifier in Models: if identifier in Models:
@ -397,7 +401,7 @@ class PrinterDriver(Commander):
self.scan(identifier, use_result=True) self.scan(identifier, use_result=True)
if self.device is None and not self.fake: if self.device is None and not self.fake:
error('no-available-devices-found', exception=PrinterError) error('no-available-devices-found', exception=PrinterError)
if mode == 'pbm' or mode == 'default': if mode in ('pbm', 'default'):
printer_data = PrinterData(self.model.paper_width, file) printer_data = PrinterData(self.model.paper_width, file)
self._print_bitmap(printer_data) self._print_bitmap(printer_data)
elif mode == 'text': elif mode == 'text':
@ -443,7 +447,7 @@ class PrinterDriver(Commander):
if self.quality: # well, slower makes stable heating if self.quality: # well, slower makes stable heating
self.set_speed(self.quality) self.set_speed(self.quality)
if self.energy is not None: if self.energy is not None:
self.set_energy(self.energy * 0xff) self.set_energy(self.energy * 0x100)
self.apply_energy() self.apply_energy()
self.update_device() self.update_device()
self.flush() self.flush()
@ -648,7 +652,7 @@ def _main():
printer = PrinterDriver() printer = PrinterDriver()
scan_param = args.scan.split(',') scan_param = args.scan.split(',')
printer.scan_timeout = float(scan_param[0]) printer.scan_time = float(scan_param[0])
identifier = ','.join(scan_param[1:]) identifier = ','.join(scan_param[1:])
if args.energy is not None: if args.energy is not None:
printer.energy = int(args.energy * 0xff) printer.energy = int(args.energy * 0xff)

View File

@ -137,7 +137,7 @@ class Commander(metaclass=ABCMeta):
def set_energy(self, amount: int): def set_energy(self, amount: int):
''' Set thermal energy, max to `0xffff` ''' Set thermal energy, max to `0xffff`
By default, it's seems around `0x3000`, aka 1 / 5. By default, it's seems around `0x3000` (1 / 5)
''' '''
self.send( self.make_command(0xaf, int_to_bytes(amount)) ) self.send( self.make_command(0xaf, int_to_bytes(amount)) )

View File

@ -3,7 +3,7 @@
🐱🖨 Ein Projekt, das Unterstützung für einige Bluetooth-"Cat Printer"-Modelle auf *vielen* Plattformen bietet! 🐱🖨 Ein Projekt, das Unterstützung für einige Bluetooth-"Cat Printer"-Modelle auf *vielen* Plattformen bietet!
[![cat-printer-poster](https://repository-images.githubusercontent.com/403563361/ad018f6e-3a6e-4028-84b2-205f7d35c22b)](https://repository-images.githubusercontent.com/403563361/ad018f6e-3a6e-4028-84b2-205f7d35c22b) [![cat-printer-poster](https://repository-images.githubusercontent.com/403563361/93e32942-856c-4552-a8b0-b03c0976a3a7)](https://repository-images.githubusercontent.com/403563361/93e32942-856c-4552-a8b0-b03c0976a3a7)
## unterstützte Geräte ## unterstützte Geräte

View File

@ -3,17 +3,14 @@
🐱🖨 猫咪打印机:此应用*跨平台地*对一些蓝牙“喵喵机”提供支持! 🐱🖨 猫咪打印机:此应用*跨平台地*对一些蓝牙“喵喵机”提供支持!
[![cat-printer-poster](https://repository-images.githubusercontent.com/403563361/ad018f6e-3a6e-4028-84b2-205f7d35c22b)](https://repository-images.githubusercontent.com/403563361/ad018f6e-3a6e-4028-84b2-205f7d35c22b) [![cat-printer-poster](https://repository-images.githubusercontent.com/403563361/93e32942-856c-4552-a8b0-b03c0976a3a7)](https://repository-images.githubusercontent.com/403563361/93e32942-856c-4552-a8b0-b03c0976a3a7)
## 型号 ## 型号
目前有: 已知支持:`GB0X, GT01, YT01` `X` 表示任意数字)
| | | 可在 Web 界面测试未列出的型号。在 `设置 -> 测试未知设备`
|----|----| 有概率成功!
| 已知支持 | `GB0X, GT01, YT01` |
\* `X` 表示任意数字
## 特性 ## 特性
@ -60,11 +57,15 @@
应用可能请求“后台位置”权限,您可以拒绝它。 应用可能请求“后台位置”权限,您可以拒绝它。
(前台)位置权限是较新版安卓系统扫描蓝牙设备所需要的。 (前台)位置权限是较新版安卓系统扫描蓝牙设备所需要的。
建议将扫描时间设为 1 秒。
### Windows ### Windows
获取名称中有 "windows" 的版本, 获取名称中有 "windows" 的版本,
解压并运行 `start.bat` 解压并运行 `start.bat`
Windows 通常需要较长的扫描时间。默认为 4 秒,可按需调整。
### GNU/Linux ### GNU/Linux
您可以获取“纯净(pure)”版,解压、在其中打开终端并输入: 您可以获取“纯净(pure)”版,解压、在其中打开终端并输入:
@ -72,6 +73,8 @@
python3 server.py python3 server.py
``` ```
建议将扫描时间设为 2 秒。
在 Arch Linux 等发行版您可能需要首先安装 `bluez` 在 Arch Linux 等发行版您可能需要首先安装 `bluez`
```bash ```bash
sudo pacman -S bluez bluez-utils sudo pacman -S bluez bluez-utils

View File

@ -49,6 +49,7 @@ mime_type = {
'json': 'application/json;charset=utf-8', 'json': 'application/json;charset=utf-8',
'png': 'image/png', 'png': 'image/png',
'svg': 'image/svg+xml;charset=utf-8', 'svg': 'image/svg+xml;charset=utf-8',
'wasm': 'application/wasm',
'octet-stream': 'application/octet-stream' 'octet-stream': 'application/octet-stream'
} }
def mime(url: str): def mime(url: str):
@ -72,10 +73,10 @@ class PrinterServerHandler(BaseHTTPRequestHandler):
settings = DictAsObject({ settings = DictAsObject({
'config_path': 'config.json', 'config_path': 'config.json',
'version': 2, 'version': 3,
'first_run': True, 'first_run': True,
'is_android': False, 'is_android': False,
'scan_timeout': 4.0, 'scan_time': 4.0,
'dry_run': False, 'dry_run': False,
'energy': 0.2 'energy': 0.2
}) })
@ -193,11 +194,13 @@ class PrinterServerHandler(BaseHTTPRequestHandler):
def update_printer(self): def update_printer(self):
'Update `PrinterDriver` state/config' 'Update `PrinterDriver` state/config'
self.printer.dry_run = self.settings.dry_run self.printer.dry_run = self.settings.dry_run
self.printer.scan_timeout = self.settings.scan_timeout self.printer.scan_time = self.settings.scan_time
self.printer.fake = self.settings.fake self.printer.fake = self.settings.fake
self.printer.dump = self.settings.dump self.printer.dump = self.settings.dump
self.printer.energy = self.settings.energy if self.settings.energy is not None:
self.printer.quality = self.settings.quality self.printer.energy = int(self.settings.energy)
if self.settings.quality is not None:
self.printer.quality = int(self.settings.quality)
self.printer.flip_h = self.settings.flip_h or self.settings.flip self.printer.flip_h = self.settings.flip_h or self.settings.flip
self.printer.flip_v = self.settings.flip_v or self.settings.flip self.printer.flip_v = self.settings.flip_v or self.settings.flip
self.printer.rtl = self.settings.force_rtl self.printer.rtl = self.settings.force_rtl
@ -218,7 +221,7 @@ class PrinterServerHandler(BaseHTTPRequestHandler):
devices_list = [{ devices_list = [{
'name': device.name, 'name': device.name,
'address': device.address 'address': device.address
} for device in self.printer.scan()] } for device in self.printer.scan(everything=data.get('everything'))]
self.api_success({ self.api_success({
'devices': devices_list 'devices': devices_list
}) })
@ -313,6 +316,7 @@ class PrinterServer(HTTPServer):
def finish_request(self, request, client_address): def finish_request(self, request, client_address):
if self.handler is None: if self.handler is None:
self.handler = self.handler_class(request, client_address, self) self.handler = self.handler_class(request, client_address, self)
self.handler.load_config()
with open(os.path.join('www', 'all_js.txt'), 'r', encoding='utf-8') as file: with open(os.path.join('www', 'all_js.txt'), 'r', encoding='utf-8') as file:
for path in file.read().split('\n'): for path in file.read().split('\n'):
if path != '': if path != '':

View File

@ -1 +1 @@
0.6.0.1 0.6.0.2

6
wasm/0-compile.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
if [[ $1 == 1 ]]; then
npm run asbuild:release;
else
npm run asbuild:debug;
fi

24
wasm/asconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"targets": {
"debug": {
"outFile": "../www/image-wasm.wasm",
"textFile": "../www/image-wasm.wat",
"sourceMap": true,
"debug": true
},
"release": {
"outFile": "../www/image-wasm.wasm",
"textFile": "../www/image-wasm.wat",
"sourceMap": true,
"optimizeLevel": 3,
"shrinkLevel": 0,
"converge": false,
"noAssert": false
}
},
"options": {
"exportRuntime": true,
"baseDir": ".",
"bindings": "esm"
}
}

115
wasm/image.ts Normal file
View File

@ -0,0 +1,115 @@
export function monoGrayscale(rgba: Uint32Array, brightness: i32, alpha_as_white: bool): Uint8ClampedArray {
let mono = new Uint8ClampedArray(rgba.length);
let r: f32 = 0.0, g: f32 = 0.0, b: f32 = 0.0, a: f32 = 0.0, m: f32 = 0.0, n: i32 = 0;
for (let i: i32 = 0; i < mono.length; ++i) {
n = rgba[i];
// little endian
r = <f32>(n & 0xff), g = <f32>(n >> 8 & 0xff), b = <f32>(n >> 16 & 0xff);
a = <f32>(n >> 24 & 0xff) / 0xff;
if (a < 1 && alpha_as_white) {
a = 1 - a;
r += (0xff - r) * a;
g += (0xff - g) * a;
b += (0xff - b) * a;
} else { r *= a; g *= a; b *= a; }
m = r * 0.2125 + g * 0.7154 + b * 0.0721;
m += <f32>(brightness - 0x80) * (<f32>1 - m / 0xff) * (m / 0xff) * 2;
mono[i] = <u8>m;
}
return mono;
}
/** Note: returns a `Uint32Array` */
export function monoToRgba(mono: Uint8ClampedArray): Uint32Array {
let rgba = new Uint32Array(mono.length);
for (let i: i32 = 0; i < mono.length; ++i) {
// little endian
rgba[i] = 0xff000000 | (mono[i] << 16) | (mono[i] << 8) | mono[i];
}
return rgba;
}
export function monoDirect(mono: Uint8ClampedArray, w: i32, h:i32): Uint8ClampedArray {
for (let i: i32 = 0; i < mono.length; ++i) {
mono[i] = mono[i] > 0x80 ? 0xff : 0x00;
}
return mono;
}
export function monoSteinberg(mono: Uint8ClampedArray, w: i32, h: i32): Uint8ClampedArray {
let p: i32 = 0, m: i32, n: i32, o: i32;
for (let j: i32 = 0; j < h; ++j) {
for (let i: i32 = 0; i < w; ++i) {
m = mono[p];
n = mono[p] > 0x80 ? 0xff : 0x00;
o = m - n;
mono[p] = n;
if (i >= 0 && i < w - 1 && j >= 0 && j < h)
mono[p + 1] += <u8>(o * 7 / 16);
if (i >= 1 && i < w && j >= 0 && j < h - 1)
mono[p + w - 1] += <u8>(o * 3 / 16);
if (i >= 0 && i < w && j >= 0 && j < h - 1)
mono[p + w] += <u8>(o * 5 / 16);
if (i >= 0 && i < w - 1 && j >= 0 && j < h - 1)
mono[p + w + 1] += <u8>(o * 1 / 16);
++p;
}
}
return mono;
}
export function monoHalftone(mono: Uint8ClampedArray, w: i32, h: i32): Uint8ClampedArray {
const spot: i32 = 4;
const spot_h: i32 = spot / 2 + 1;
const spot_d: i32 = spot * 2;
const spot_s: i32 = spot * spot;
let i: i32, j: i32, x: i32, y: i32, o: f64 = 0.0;
for (j = 0; j < h - spot; j += spot) {
for (i = 0; i < w - spot; i += spot) {
for (x = 0; x < spot; ++x)
for (y = 0; y < spot; ++y)
o += mono[(j + y) * w + i + x];
o = (1 - o / spot_s / 0xff) * spot;
for (x = 0; x < spot; ++x)
for (y = 0; y < spot; ++y) {
mono[(j + y) * w + i + x] = Math.abs(x - spot_h) >= o || Math.abs(y - spot_h) >= o ? 0xff : 0x00;
// mono[(j + y) * w + i + x] = Math.abs(x - spot_h) + Math.abs(y - spot_h) >= o ? 0xff : 0x00;
}
}
for (; i < w; ++i) mono[j * w + i] = 0xff;
}
for (; j < h; ++j)
for (i = 0; i < w; ++i) mono[j * w + i] = 0xff;
return mono;
}
export function monoToPbm(data: Uint8ClampedArray): Uint8ClampedArray {
let length: i32 = (data.length / 8) | 0;
let result = new Uint8ClampedArray(length);
for (let i: i32 = 0, p: i32 = 0; i < data.length; ++p) {
result[p] = 0;
for (let d: u8 = 0; d < 8; ++i, ++d)
result[p] |= data[i] & (0b10000000 >> d);
result[p] ^= 0b11111111;
}
return result;
}
/** Note: takes & gives `Uint32Array` */
export function rotateRgba(before: Uint32Array, w: i32, h: i32): Uint32Array {
/**
* w h
* o------+ +---o
* h | | | | w
* +------+ | | after
* before +---+
*/
let after = new Uint32Array(before.length);
for (let j: i32 = 0; j < h; j++) {
for (let i: i32 = 0; i < w; i++) {
after[j * w + i] = before[(w - i - 1) * h + j];
}
}
return after;
}

64
wasm/package-lock.json generated Normal file
View File

@ -0,0 +1,64 @@
{
"name": "wasm",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"dependencies": {
"assemblyscript": "^0.20.13"
}
},
"node_modules/assemblyscript": {
"version": "0.20.13",
"resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.20.13.tgz",
"integrity": "sha512-F4ACXdBdXCBnPEzRCl/ovFFPGmKXQPvWW4cM6U21eRezaCoREqxA0hjSUOYTz7txY3MJclyBfjwMSEJ5e9HKsw==",
"dependencies": {
"binaryen": "108.0.0-nightly.20220528",
"long": "^5.2.0"
},
"bin": {
"asc": "bin/asc.js",
"asinit": "bin/asinit.js"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/assemblyscript"
}
},
"node_modules/binaryen": {
"version": "108.0.0-nightly.20220528",
"resolved": "https://registry.npmjs.org/binaryen/-/binaryen-108.0.0-nightly.20220528.tgz",
"integrity": "sha512-9biG357fx3NXmJNotIuY9agZBcCNHP7d1mgOGaTlPYVHZE7/61lt1IyHCXAL+W5jUOYgmFZ260PR4IbD19RKuA==",
"bin": {
"wasm-opt": "bin/wasm-opt",
"wasm2js": "bin/wasm2js"
}
},
"node_modules/long": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz",
"integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w=="
}
},
"dependencies": {
"assemblyscript": {
"version": "0.20.13",
"resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.20.13.tgz",
"integrity": "sha512-F4ACXdBdXCBnPEzRCl/ovFFPGmKXQPvWW4cM6U21eRezaCoREqxA0hjSUOYTz7txY3MJclyBfjwMSEJ5e9HKsw==",
"requires": {
"binaryen": "108.0.0-nightly.20220528",
"long": "^5.2.0"
}
},
"binaryen": {
"version": "108.0.0-nightly.20220528",
"resolved": "https://registry.npmjs.org/binaryen/-/binaryen-108.0.0-nightly.20220528.tgz",
"integrity": "sha512-9biG357fx3NXmJNotIuY9agZBcCNHP7d1mgOGaTlPYVHZE7/61lt1IyHCXAL+W5jUOYgmFZ260PR4IbD19RKuA=="
},
"long": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz",
"integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w=="
}
}
}

10
wasm/package.json Normal file
View File

@ -0,0 +1,10 @@
{
"dependencies": {
"assemblyscript": "^0.20.13"
},
"scripts": {
"asbuild:debug": "asc image.ts --target debug && tsc",
"asbuild:release": "asc image.ts --target release && tsc",
"asbuild": "npm run asbuild:release"
}
}

12
wasm/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "assemblyscript/std/assembly.json",
"include": [
"./**/*.ts"
],
"compilerOptions": {
"experimentalDecorators": true,
"outDir": "../www/",
"module": "None",
"declaration": true
}
}

9
www/image.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
export declare function monoGrayscale(rgba: Uint32Array, brightness: i32, alpha_as_white: bool): Uint8ClampedArray;
/** Note: returns a `Uint32Array` */
export declare function monoToRgba(mono: Uint8ClampedArray): Uint32Array;
export declare function monoDirect(mono: Uint8ClampedArray, w: i32, h: i32): Uint8ClampedArray;
export declare function monoSteinberg(mono: Uint8ClampedArray, w: i32, h: i32): Uint8ClampedArray;
export declare function monoHalftone(mono: Uint8ClampedArray, w: i32, h: i32): Uint8ClampedArray;
export declare function monoToPbm(data: Uint8ClampedArray): Uint8ClampedArray;
/** Note: takes & gives `Uint32Array` */
export declare function rotateRgba(before: Uint32Array, w: i32, h: i32): Uint32Array;

View File

@ -1,117 +1,125 @@
"use strict";
/** Object.defineProperty(exports, "__esModule", { value: true });
* Convert colored image to grayscale. exports.rotateRgba = exports.monoToPbm = exports.monoHalftone = exports.monoSteinberg = exports.monoDirect = exports.monoToRgba = exports.monoGrayscale = void 0;
* @param {Uint8ClampedArray} image_data `data` property of an ImageData instance, function monoGrayscale(rgba, brightness, alpha_as_white) {
* i.e. `canvas.getContext('2d').getImageData(...).data` let mono = new Uint8ClampedArray(rgba.length);
* @param {Uint8ClampedArray} mono_data an `Uint8ClampedArray` that have the size `w * h` let r = 0.0, g = 0.0, b = 0.0, a = 0.0, m = 0.0, n = 0;
* i.e. `image_data.length / 4` for (let i = 0; i < mono.length; ++i) {
* The result data will be here, as a 8-bit grayscale image data. n = rgba[i];
* @param {number} w width of image // little endian
* @param {number} h height of image r = (n & 0xff), g = (n >> 8 & 0xff), b = (n >> 16 & 0xff);
* @param {number} t brightness, historically "threshold" a = (n >> 24 & 0xff) / 0xff;
* @param {boolean} transparencyAsWhite whether render opacity as white rather than black if (a < 1 && alpha_as_white) {
a = 1 - a;
r += (0xff - r) * a;
g += (0xff - g) * a;
b += (0xff - b) * a;
}
else {
r *= a;
g *= a;
b *= a;
}
m = r * 0.2125 + g * 0.7154 + b * 0.0721;
m += (brightness - 0x80) * (1 - m / 0xff) * (m / 0xff) * 2;
mono[i] = m;
}
return mono;
}
exports.monoGrayscale = monoGrayscale;
/** Note: returns a `Uint32Array` */
function monoToRgba(mono) {
let rgba = new Uint32Array(mono.length);
for (let i = 0; i < mono.length; ++i) {
// little endian
rgba[i] = 0xff000000 | (mono[i] << 16) | (mono[i] << 8) | mono[i];
}
return rgba;
}
exports.monoToRgba = monoToRgba;
function monoDirect(mono, w, h) {
for (let i = 0; i < mono.length; ++i) {
mono[i] = mono[i] > 0x80 ? 0xff : 0x00;
}
return mono;
}
exports.monoDirect = monoDirect;
function monoSteinberg(mono, w, h) {
let p = 0, m, n, o;
for (let j = 0; j < h; ++j) {
for (let i = 0; i < w; ++i) {
m = mono[p];
n = mono[p] > 0x80 ? 0xff : 0x00;
o = m - n;
mono[p] = n;
if (i >= 0 && i < w - 1 && j >= 0 && j < h)
mono[p + 1] += (o * 7 / 16);
if (i >= 1 && i < w && j >= 0 && j < h - 1)
mono[p + w - 1] += (o * 3 / 16);
if (i >= 0 && i < w && j >= 0 && j < h - 1)
mono[p + w] += (o * 5 / 16);
if (i >= 0 && i < w - 1 && j >= 0 && j < h - 1)
mono[p + w + 1] += (o * 1 / 16);
++p;
}
}
return mono;
}
exports.monoSteinberg = monoSteinberg;
function monoHalftone(mono, w, h) {
const spot = 4;
const spot_h = spot / 2 + 1;
const spot_d = spot * 2;
const spot_s = spot * spot;
let i, j, x, y, o = 0.0;
for (j = 0; j < h - spot; j += spot) {
for (i = 0; i < w - spot; i += spot) {
for (x = 0; x < spot; ++x)
for (y = 0; y < spot; ++y)
o += mono[(j + y) * w + i + x];
o = (1 - o / spot_s / 0xff) * spot;
for (x = 0; x < spot; ++x)
for (y = 0; y < spot; ++y) {
mono[(j + y) * w + i + x] = Math.abs(x - spot_h) >= o || Math.abs(y - spot_h) >= o ? 0xff : 0x00;
// mono[(j + y) * w + i + x] = Math.abs(x - spot_h) + Math.abs(y - spot_h) >= o ? 0xff : 0x00;
}
}
for (; i < w; ++i)
mono[j * w + i] = 0xff;
}
for (; j < h; ++j)
for (i = 0; i < w; ++i)
mono[j * w + i] = 0xff;
return mono;
}
exports.monoHalftone = monoHalftone;
function monoToPbm(data) {
let length = (data.length / 8) | 0;
let result = new Uint8ClampedArray(length);
for (let i = 0, p = 0; i < data.length; ++p) {
result[p] = 0;
for (let d = 0; d < 8; ++i, ++d)
result[p] |= data[i] & (0b10000000 >> d);
result[p] ^= 0b11111111;
}
return result;
}
exports.monoToPbm = monoToPbm;
/** Note: takes & gives `Uint32Array` */
function rotateRgba(before, w, h) {
/**
* w h
* o------+ +---o
* h | | | | w
* +------+ | | after
* before +---+
*/ */
function monoGrayscale(image_data, mono_data, w, h, t, transparencyAsWhite) { let after = new Uint32Array(before.length);
let p, q, r, g, b, a, m;
for (let j = 0; j < h; j++) { for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) { for (let i = 0; i < w; i++) {
p = j * w + i; after[j * w + i] = before[(w - i - 1) * h + j];
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);
m += (t - 128) * (1 - m / 255) * (m / 255) * 2;
mono_data[p] = m;
} }
} }
return after;
} }
exports.rotateRgba = rotateRgba;
/**
* 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
*/
function monoDirect(data, w, h) {
let p, i, j;
for (j = 0; j < h; j++) {
for (i = 0; i < w; i++) {
p = j * w + i;
data[p] = data[p] > 128 ? 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
*/
function monoSteinberg(data, w, h) {
let p, m, n, o, i, j;
function adjust(x, y, delta) {
if (
x < 0 || x >= w ||
y < 0 || y >= h
) return;
p = y * w + x;
data[p] += delta;
}
for (j = 0; j < h; j++) {
for (i = 0; i < w; i++) {
p = j * w + i;
m = data[p];
n = m > 128 ? 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) {}
/**
* 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, i;
for (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
// assuming there's only 255 (0b11111111) or 0 (0b00000000) in the data
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

@ -30,6 +30,10 @@
<input type="radio" name="algo" value="algo-steinberg" data-key checked /> <input type="radio" name="algo" value="algo-steinberg" data-key checked />
<span data-i18n="picture">Picture</span> <span data-i18n="picture">Picture</span>
</label> </label>
<label>
<input type="radio" name="algo" value="algo-halftone" data-key />
<span data-i18n="pattern">Pattern</span>
</label>
<label> <label>
<input type="radio" name="algo" value="algo-direct" data-key /> <input type="radio" name="algo" value="algo-direct" data-key />
<span data-i18n="text">Text</span> <span data-i18n="text">Text</span>
@ -99,6 +103,7 @@
<span>🌎</span> <span>🌎</span>
<span data-i18n="accessibility">Accessibility</span> <span data-i18n="accessibility">Accessibility</span>
</button> </button>
<button id="test-unknown-device" data-i18n="test-unknown-device" data-key>Test Unknown Device</button>
<button class="hidden" data-panel="panel-error" data-i18n="error-message" data-key>Error Message</button> <button class="hidden" data-panel="panel-error" data-i18n="error-message" data-key>Error Message</button>
<div class="center"> <div class="center">
<button id="button-exit" data-i18n="exit" data-key>Exit</button> <button id="button-exit" data-i18n="exit" data-key>Exit</button>
@ -156,6 +161,7 @@
<br /> <br />
<span id="hint-tab-control" class="hide-on-android" <span id="hint-tab-control" class="hide-on-android"
data-i18n="to-enter-keyboard-mode-press-tab">To enter Keyboard Mode, press Tab</span> data-i18n="to-enter-keyboard-mode-press-tab">To enter Keyboard Mode, press Tab</span>
<br />
</div> </div>
<div> <div>
<h2 data-i18n="layout">Layout</h2> <h2 data-i18n="layout">Layout</h2>

View File

@ -137,5 +137,7 @@
"serif": "Serif", "serif": "Serif",
"sans-serif": "Sans Serif", "sans-serif": "Sans Serif",
"monospace": "Monospace", "monospace": "Monospace",
"rotate-image": "Rotate Image" "rotate-image": "Rotate Image",
"test-unknown-device": "Test Unknown Device",
"now-will-scan-for-all-bluetooth-devices-nearby": "Now will scan for all bluetooth devices nearby."
} }

View File

@ -132,5 +132,7 @@
"serif": "SHARP PAW", "serif": "SHARP PAW",
"sans-serif": "SOFT PAW", "sans-serif": "SOFT PAW",
"monospace": "H4CKY PAW", "monospace": "H4CKY PAW",
"rotate-image": "ROLL PIC" "rotate-image": "ROLL PIC",
"test-unknown-device": "I HAV STRENGE KITTE",
"now-will-scan-for-all-bluetooth-devices-nearby": "WIL FIND ALL THINY KITTE OR NOT"
} }

View File

@ -128,5 +128,7 @@
"serif": "衬线字体", "serif": "衬线字体",
"sans-serif": "无衬线字体", "sans-serif": "无衬线字体",
"monospace": "等宽字体", "monospace": "等宽字体",
"rotate-image": "旋转图像" "rotate-image": "旋转图像",
"test-unknown-device": "测试未知设备",
"now-will-scan-for-all-bluetooth-devices-nearby": "现在将搜索附近所有设备。"
} }

View File

@ -4,6 +4,8 @@ Copyright © 2021-2022 NaitLee Soft. No rights reserved.
License CC0-1.0-only: https://directory.fsf.org/wiki/License:CC0 License CC0-1.0-only: https://directory.fsf.org/wiki/License:CC0
`; `;
window.exports = {};
/** /**
* Satisfy both development and old-old webView need * Satisfy both development and old-old webView need
*/ */

View File

@ -36,6 +36,7 @@ body {
margin: 1em 0; margin: 1em 0;
user-select: none; user-select: none;
} }
body, .shade { transition: background-color calc(var(--anim-time) * 2) ease-in; }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -179,7 +180,7 @@ main, header, footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: row; flex-direction: row;
overflow-x: hidden; /* overflow-x: hidden; */ /* this causes sticky position not work */
} }
.canvas-side { .canvas-side {
flex-grow: 0; flex-grow: 0;
@ -204,6 +205,13 @@ main, header, footer {
border-bottom: none; border-bottom: none;
margin-top: var(--span); margin-top: var(--span);
} }
.canvas-side>.buttons {
position: sticky;
bottom: 0;
padding: var(--span) 0;
background-color: var(--back-color);
z-index: 1;
}
.compact-menu { .compact-menu {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -300,6 +308,9 @@ input[type="range"] {
#hidden, .hard-hidden { #hidden, .hard-hidden {
display: none; display: none;
} }
#hint-tab-control {
position: relative;
}
#error-record { #error-record {
font-family: 'DejaVu Sans Mono', 'Consolas', monospace; font-family: 'DejaVu Sans Mono', 'Consolas', monospace;
width: 100%; width: 100%;
@ -378,7 +389,7 @@ iframe#frame {
#dialog>.content { #dialog>.content {
max-width: 100%; max-width: 100%;
width: 42em; width: 42em;
max-height: 80vh; max-height: 100vh;
margin: 12vh auto; margin: 12vh auto;
border: var(--border) solid var(--fore-color); border: var(--border) solid var(--fore-color);
transition: transform var(--anim-time) var(--curve); transition: transform var(--anim-time) var(--curve);
@ -391,18 +402,18 @@ iframe#frame {
transform: scaleY(0); transform: scaleY(0);
} }
#dialog-content { #dialog-content {
max-height: 60vh;
margin: auto; margin: auto;
padding: var(--span-double); padding: var(--span-double);
padding-bottom: 0; padding-bottom: 0;
display: flex; max-height: calc(76vh - 1em);
flex-direction: column; overflow-y: auto;
justify-content: center;
} }
#dialog-choices { #dialog-choices {
margin: auto; margin: auto;
padding: var(--span); padding: var(--span);
padding-top: 0; padding-top: 0;
position: sticky;
bottom: 0;
} }
#choice-input { #choice-input {
max-width: 100%; max-width: 100%;
@ -412,13 +423,12 @@ iframe#frame {
text-align: initial; text-align: initial;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} justify-content: space-around;
#accessibility>*:nth-child(1) { flex-wrap: wrap;
flex-grow: 1;
} }
#select-language { #select-language {
/* width: calc(100% - var(--span-double)); */ /* width: calc(100% - var(--span-double)); */
width: 12em; width: 100%;
height: 8em; height: 8em;
border: var(--border) solid var(--fore-color); border: var(--border) solid var(--fore-color);
padding: var(--span); padding: var(--span);
@ -430,9 +440,11 @@ iframe#frame {
#select-language option:hover { #select-language option:hover {
text-decoration: underline; text-decoration: underline;
} }
#accessibility>*:nth-child(2) { #accessibility>* {
flex-grow: 1; flex-grow: 0;
min-width: 16em;
white-space: nowrap; white-space: nowrap;
margin: 1em;
} }
@keyframes jump { @keyframes jump {
0% { transform: translateY(0); } 0% { transform: translateY(0); }
@ -542,14 +554,7 @@ a {
} }
.canvas-side>.buttons, .canvas-side>.buttons,
.menu-side>.buttons { .menu-side>.buttons {
position: sticky;
bottom: var(--span);
width: 100%; width: 100%;
z-index: 1;
}
.canvas-side>.buttons button,
.menu-side>.buttons button {
background-color: var(--back-color);
} }
#control-overlay { #control-overlay {
width: 100%; width: 100%;
@ -590,7 +595,7 @@ a {
height: var(--compact-menu-height); height: var(--compact-menu-height);
} }
} }
@media (max-width: 384px) { @media (max-width: 385px) {
#preview, #canvas, #control-overlay, .canvas-side>* { #preview, #canvas, #control-overlay, .canvas-side>* {
width: 100%; width: 100%;
border: none; border: none;
@ -607,19 +612,17 @@ a {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { --fore-color: #eee; --back-color: #333; --shade: rgba(51, 51, 51, 0.5); } :root { --fore-color: #eee; --back-color: #333; --shade: rgba(51, 51, 51, 0.5); }
body, .shade { transition: background-color calc(var(--anim-time) * 2) ease-in; }
a:link, a:visited { color: #66f; } a:link, a:visited { color: #66f; }
a:hover, a:active { color: #77f; } a:hover, a:active { color: #77f; }
.canvas-group, .logo { filter: brightness(0.6); } .canvas-group, .logo { filter: brightness(0.6); }
#control-overlay { background-color: var(--shade); } #control-overlay { background-color: var(--shade); }
} }
/* so silly... */ /* so silly... */
body.dark { --fore-color: #eee; --back-color: #333; --shade: rgba(51, 51, 51, 0.5); } body.dark-theme { --fore-color: #eee; --back-color: #333; --shade: rgba(51, 51, 51, 0.5); }
body.dark, .shade { transition: background-color calc(var(--anim-time) * 2) ease-in; } body.dark-theme a:link, body.dark-theme a:visited { color: #66f; }
body.dark a:link, body.dark a:visited { color: #66f; } body.dark-theme a:hover, body.dark-theme a:active { color: #77f; }
body.dark a:hover, body.dark a:active { color: #77f; } body.dark-theme .canvas-group, body.dark-theme .logo { filter: brightness(0.6); }
body.dark .canvas-group, body.dark .logo { filter: brightness(0.6); } body.dark-theme #control-overlay { background-color: var(--shade); }
body.dark #control-overlay { background-color: var(--shade); }
@media (prefers-reduced-motion) { @media (prefers-reduced-motion) {
body *, body *,
body *::before, body *::before,

View File

@ -1,3 +1,4 @@
` `
Cat-Printer: Web Frontend Cat-Printer: Web Frontend
@ -205,62 +206,89 @@ async function callApi(path, body, errorPreHandler) {
} }
}); });
} }
/**
* call addEventListener on all selected elements by `seletor`, const Ev = (function() {
* with each element itself as `this` unless specifyed `thisArg`, /** @type {Record<string, NodeListOf<HTMLElement>>} */
* with type `type` and a function `callback`. let map = {};
* If an element have attribute `data-default` or `checked`, dispatch event immediately on it. return {
* You can of course assign resulting object to a variable for futher use.
*/
class EventPutter {
elements;
callback;
/** /**
* Attach event & callback to elements selected by selector.
* @param {string} selector * @param {string} selector
* @param {string} type * @param {string} type
* @param {(event?: Event) => void} callback * @param {(event?: Event) => void} callback
* @param {any} thisArg * @param {any} thisArg
*/ */
constructor(selector, type, callback, thisArg) { put: function(selector, type, callback, thisArg) {
let elements = this.elements = document.querySelectorAll(selector); let elements = document.querySelectorAll(selector);
if (elements.length === 0) return; map[selector] = elements;
this.callback = callback; for (let e of elements) {
elements.forEach(element => { e.addEventListener(type, function(event) {
element.addEventListener(type, function(event) {
event.stopPropagation(); event.stopPropagation();
event.cancelBubble = true; event.cancelBubble = true;
callback.call(thisArg || element, event); callback.call(thisArg || e, event);
}); });
if (element.hasAttribute('data-default') || element.checked) { if (e.hasAttribute('data-default') || e.checked) {
element.dispatchEvent(new Event(type)); e.dispatchEvent(new Event(type));
} }
});
} }
} },
/** /**
* Dispatch event to elements that are selected before with the same selector.
* Optionally set a value.
* @param {string} selector * @param {string} selector
* @param {string} type * @param {string} type
* @param {(event?: Event) => void} callback * @param {{
* @param {any} thisArg * event?: Event,
* value?: string | number | boolean
* }} args
*/ */
function putEvent(selector, type, callback, thisArg) { dispatch: function(selector, type, { event, value } = {}) {
return new EventPutter(selector, type, callback, thisArg); if (map[selector] === undefined) return;
} for (let e of map[selector]) {
if (value !== undefined)
switch (e.type) {
case 'checkbox':
if (e.checked === !value) e.click();
break;
case 'radio':
if (e.value === value) e.click();
break;
case 'text':
case 'number':
case 'range':
e.value = value;
break;
default:
if (e.value === value) e.click();
}
else e.dispatchEvent(event || new Event(type));
}
}
};
})();
(function() { /**
* Open a panel
* @type {(id: string) => void}
*/
const Panel = (function() {
let panels = document.querySelectorAll('.panel'); let panels = document.querySelectorAll('.panel');
let buttons = document.querySelectorAll('*[data-panel]'); let buttons = document.querySelectorAll('*[data-panel]');
panels.forEach(panel => { let map = {};
for (let panel of panels) {
let button = document.querySelector(`*[data-panel="${panel.id}"]`); let button = document.querySelector(`*[data-panel="${panel.id}"]`);
if (button) button.addEventListener('click', event => { if (!button) continue;
button.addEventListener('click', event => {
event.stopPropagation(); event.stopPropagation();
panels.forEach(p => p.classList.remove('active')); panels.forEach(p => p.classList.remove('active'));
buttons.forEach(b => b.classList.remove('active')); buttons.forEach(b => b.classList.remove('active'));
panel.classList.add('active'); panel.classList.add('active');
button.classList.add('active'); button.classList.add('active');
}); });
map[panel.id] = button;
if (panel.hasAttribute('data-default')) button.click(); if (panel.hasAttribute('data-default')) button.click();
}); }
return id => map[id]?.click();
})(); })();
/** /**
@ -273,33 +301,33 @@ class CanvasController {
/** @type {HTMLCanvasElement} */ /** @type {HTMLCanvasElement} */
canvas; canvas;
imageUrl; imageUrl;
isImageNew;
// this costs most of the effort. cache it
/** @type {Uint8ClampedArray} */
grayscaleCache;
algorithm; algorithm;
algoElements;
textFont; textFont;
textSize; textSize;
textArea; textArea;
transparentAsWhite; transparentAsWhite;
previewData; previewPbm;
rotate; rotate;
#height; #height;
#threshold; #threshold;
#energy; #energy;
#thresholdRange;
#energyRange;
#rotateCheck;
static defaultHeight = 384; static defaultHeight = 384;
static defaultThreshold = 256 / 3; static defaultThreshold = 256 / 3;
get threshold() { get threshold() {
return this.#threshold; return this.#threshold;
} }
set threshold(value) { set threshold(value) {
this.#threshold = this.#thresholdRange.value = value; Ev.dispatch('#threshold', 'change', { value: this.#threshold = value });
} }
get energy() { get energy() {
return this.#energy; return this.#energy;
} }
set energy(value) { set energy(value) {
this.#energy = this.#energyRange.value = value; Ev.dispatch('#energy', 'change', { value: this.#energy = value });
} }
get height() { get height() {
return this.#height; return this.#height;
@ -316,11 +344,11 @@ class CanvasController {
this.textArea = document.getElementById("insert-text-area"); this.textArea = document.getElementById("insert-text-area");
this.wrapBySpace = document.querySelector('input[name="wrap-words-by-spaces"]'); this.wrapBySpace = document.querySelector('input[name="wrap-words-by-spaces"]');
this.height = CanvasController.defaultHeight; this.height = CanvasController.defaultHeight;
this.#thresholdRange = document.querySelector('[name="threshold"]');
this.#energyRange = document.querySelector('[name="energy"]');
this.imageUrl = null; this.imageUrl = null;
this.textAlign = "left"; this.textAlign = "left";
this.rotate = false; this.rotate = false;
this.isImageNew = true;
this.grayscaleCache = null;
for (let elem of document.querySelectorAll("input[name=text-align]")){ for (let elem of document.querySelectorAll("input[name=text-align]")){
if (elem.checked) { this.textAlign = elem.value; } if (elem.checked) { this.textAlign = elem.value; }
@ -351,47 +379,44 @@ class CanvasController {
this.textArea.style["font-family"] = this.textFont.value; this.textArea.style["font-family"] = this.textFont.value;
this.textArea.style["word-break"] = this.wrapBySpace.checked ? "break-word" : "break-all"; this.textArea.style["word-break"] = this.wrapBySpace.checked ? "break-word" : "break-all";
this.algoElements = document.querySelectorAll('input[name="algo"]'); Ev.put('[name="algo"]' , 'change', (event) => this.useAlgorithm(event.currentTarget.value), this);
Ev.put('#insert-picture', 'click' , () => this.useFiles(), this);
putEvent('input[name="algo"]', 'change', (event) => this.useAlgorithm(event.currentTarget.value), this); Ev.put('#insert-text' , 'click' , () => Dialog.alert("#text-input", () => this.insertText(this.textArea.value)));
putEvent('#insert-picture' , 'click', () => this.useFiles(), this); Ev.put('#text-size' , 'change', () => this.textArea.style["font-size"] = this.textSize.value + "px");
putEvent('#insert-text' , 'click', () => Dialog.alert("#text-input", () => this.insertText(this.textArea.value))); Ev.put('#text-font' , 'change', () => this.textArea.style["font-family"] = this.textFont.value);
putEvent('#text-size' , 'change', () => this.textArea.style["font-size"] = this.textSize.value + "px"); Ev.put('input[name="text-align"]', 'change', (event) => {
putEvent('#text-font' , 'change', () => this.textArea.style["font-family"] = this.textFont.value); this.textAlign = event.currentTarget.value;
putEvent('input[name="text-align"]', 'change', (event) => {
this.textAlign = event.currentTarget.value
this.textArea.style["text-align"] = this.textAlign; this.textArea.style["text-align"] = this.textAlign;
}, this); }, this);
putEvent('input[name="wrap-words-by-spaces"]' , 'change', () => this.textArea.style["word-break"] = this.wrapBySpace.checked ? "break-word" : "break-all"); Ev.put('input[name="wrap-words-by-spaces"]', 'change',
putEvent('#button-preview' , 'click', this.activatePreview , this); () => this.textArea.style["word-break"] = this.wrapBySpace.checked ? "break-word" : "break-all");
putEvent('#button-reset' , 'click', this.reset , this); Ev.put('#button-preview' , 'click', this.activatePreview , this);
putEvent('#canvas-expand' , 'click', this.expand , this); Ev.put('#button-reset' , 'click', this.reset , this);
putEvent('#canvas-crop' , 'click', this.crop , this); Ev.put('#canvas-expand' , 'click', this.expand , this);
putEvent('[name="rotate"]' , 'change', e => this.setRotate(e.currentTarget.checked), this); Ev.put('#canvas-crop' , 'click', this.crop , this);
this.#rotateCheck = document.querySelector('[name="rotate"]'); Ev.put('[name="rotate"]' , 'change', e => this.setRotate(e.currentTarget.checked), this);
putEvent('[name="threshold"]', 'change', (event) => { Ev.put('[name="threshold"]', 'change', (event) => {
this.threshold = parseInt(event.currentTarget.value); this.threshold = parseInt(event.currentTarget.value);
// it's really new
this.isImageNew = true;
this.activatePreview(); this.activatePreview();
}, this); }, this);
putEvent('[name="energy"]', 'change', (event) => { Ev.put('[name="energy"]', 'change', (event) => {
this.energy = parseInt(event.currentTarget.value); this.energy = parseInt(event.currentTarget.value);
this.visualEnergy(this.energy); this.visualEnergy(this.energy);
}, this); }, this);
putEvent('[name="transparent-as-white"]', 'change', (event) => { Ev.put('[name="transparent-as-white"]', 'change', (event) => {
this.transparentAsWhite = event.currentTarget.checked; this.transparentAsWhite = event.currentTarget.checked;
this.isImageNew = true;
this.activatePreview(); this.activatePreview();
}, this); }, this);
} }
useAlgorithm(name) { useAlgorithm(name) {
for (let e of this.algoElements) {
e.checked = e.value === name;
}
this.algorithm = name; this.algorithm = name;
this.threshold = CanvasController.defaultThreshold; // Ev.dispatch('[name="algo"]', 'change', { value: name });
this.#thresholdRange.dispatchEvent(new Event('change')); Ev.dispatch('[name="threshold"]', 'change', { value: CanvasController.defaultThreshold });
this.energy = name == 'algo-direct' ? 96 : 64; Ev.dispatch('[name="energy"]', 'change', { value: (name == 'algo-direct' ? 96 : 64) });
this.#energyRange.dispatchEvent(new Event('change'));
this.activatePreview(); this.activatePreview();
} }
expand(length = CanvasController.defaultHeight) { expand(length = CanvasController.defaultHeight) {
@ -401,7 +426,6 @@ class CanvasController {
// STUB // STUB
} }
setRotate(value) { setRotate(value) {
this.#rotateCheck.checked = value;
this.rotate = value; this.rotate = value;
if (this.imageUrl !== null) this.putImage(this.imageUrl); if (this.imageUrl !== null) this.putImage(this.imageUrl);
} }
@ -414,87 +438,71 @@ class CanvasController {
activatePreview() { activatePreview() {
if (!this.imageUrl) return; if (!this.imageUrl) return;
let preview = this.preview; let preview = this.preview;
let t = Math.min(this.threshold, 255); let threshold = Math.min(this.threshold, 255);
let canvas = this.canvas; let canvas = this.canvas;
let w = canvas.width, h = canvas.height; let w = canvas.width, h = canvas.height;
preview.width = w; preview.height = h; preview.width = w; preview.height = h;
let context_c = canvas.getContext('2d'); let context_c = canvas.getContext('2d');
let context_p = preview.getContext('2d'); let context_p = preview.getContext('2d');
let data = context_c.getImageData(0, 0, w, h); let rgba_data = context_c.getImageData(0, 0, w, h);
let mono_data = new Uint8ClampedArray(w * h); let gray_data = (this.grayscaleCache =
monoGrayscale(data.data, mono_data, w, h, t, this.transparentAsWhite); this.isImageNew || !this.grayscaleCache
? monoGrayscale(
new Uint32Array(rgba_data.data.buffer),
threshold,
this.transparentAsWhite
)
: this.grayscaleCache).slice();
/** @type {Uint8ClampedArray} */
let result;
switch (this.algorithm) { switch (this.algorithm) {
case 'algo-direct': case 'algo-direct':
monoDirect(mono_data, w, h, t); result = monoDirect(gray_data, w, h);
break; break;
case 'algo-steinberg': case 'algo-steinberg':
monoSteinberg(mono_data, w, h, Math.floor(t / 2 - 64)); result = monoSteinberg(gray_data, w, h);
break; break;
case 'algo-halftone': case 'algo-halftone':
// monoHalftone(mono_data, w, h, t); result = monoHalftone(gray_data, w, h);
// Sorry, do it later
break;
case 'algo-new':
monoNew(mono_data, w, h, t);
break;
case 'algo-new-h':
monoNewH(mono_data, w, h, Math.floor(t / 2 - 64));
break;
case 'algo-new-v':
monoNewV(mono_data, w, h, t);
break;
case 'algo-legacy':
monoLegacy(mono_data, w, h, t);
break; break;
} }
let new_data = context_p.createImageData(w, h); this.previewPbm = monoToPbm(result);
let p; let rgba = new Uint8ClampedArray(monoToRgba(result).buffer);
for (let i = 0; i < mono_data.length; i++) { context_p.putImageData(new ImageData(rgba, w, h), 0, 0);
p = i * 4; this.isImageNew = false;
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);
} }
putImage(url) { putImage(url) {
let before = document.createElement('canvas');
let b_ctx = before.getContext('2d');
let img = document.getElementById('img'); let img = document.getElementById('img');
img.src = url; img.src = url;
img.addEventListener('load', () => { img.addEventListener('load', () => {
let canvas = this.canvas; let canvas = this.canvas;
let ctx = canvas.getContext('2d'); let ctx = canvas.getContext('2d');
if (this.rotate) { if (this.rotate) {
let intermediate_canvas = document.createElement('canvas');
/**
* w h
* +------+ +---+
* h | | | | w
* +------+ | | intermediate_canvas
* canvas +---+
*/
let w = canvas.width; let w = canvas.width;
let h = this.height = Math.floor(canvas.width * img.width / img.height); let h = this.height = Math.floor(canvas.width * img.width / img.height);
intermediate_canvas.width = h; before.width = h, before.height = w;
intermediate_canvas.height = w; b_ctx.drawImage(img, 0, 0, h, w);
let i_ctx = intermediate_canvas.getContext('2d'); let data = new ImageData(
i_ctx.drawImage(img, 0, 0, h, w); new Uint8ClampedArray(
let i_data = i_ctx.getImageData(0, 0, h, w); rotateRgba(
let data = ctx.createImageData(w, h); new Uint32Array(
for (let j = 0; j < h; j++) { b_ctx.getImageData(0, 0, h, w).data.buffer
for (let i = 0; i < w; i++) { ), w, h
for (let d = 0; d < 4; d++) ).buffer
data.data[(i * 4 + d) + (j * w * 4)] = i_data.data[(j * 4 + d) + ((w - i) * 4 * h)]; ), w, h
} );
}
ctx.putImageData(data, 0, 0); ctx.putImageData(data, 0, 0);
} else { } else {
this.height = Math.floor(canvas.width * img.height / img.width); this.height = Math.floor(canvas.width * img.height / img.width);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
} }
this.crop(); this.crop();
this.isImageNew = true;
this.activatePreview(); this.activatePreview();
hint('#button-print'); hint('#button-print');
}); }, { once: true });
} }
useFiles(files) { useFiles(files) {
const use_files = (files) => { const use_files = (files) => {
@ -607,6 +615,7 @@ class CanvasController {
let canvas = this.canvas; let canvas = this.canvas;
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
canvas.height = CanvasController.defaultHeight; canvas.height = CanvasController.defaultHeight;
this.isImageNew = true;
this.activatePreview(); this.activatePreview();
this.imageUrl = null; this.imageUrl = null;
this.controls.classList.remove('hidden'); this.controls.classList.remove('hidden');
@ -617,7 +626,7 @@ class CanvasController {
} }
} }
makePbm() { makePbm() {
let blob = mono2pbm(this.previewData, this.preview.width, this.preview.height); let blob = new Blob([`P4\n${this.canvas.width} ${this.canvas.height}\n`, this.previewPbm]);
return blob; return blob;
} }
} }
@ -698,10 +707,10 @@ class Main {
/** @type {CanvasController} */ /** @type {CanvasController} */
canvasController; canvasController;
deviceOptions; deviceOptions;
testUnknownDevice;
/** An object containing configuration, fetched from server */ /** An object containing configuration, fetched from server */
settings; settings;
/** @type {{ [key: string]: EventPutter }} */ selectorMap;
setters;
/** /**
* There are race conditions in initialization query/set, * There are race conditions in initialization query/set,
* use this flag to avoid * use this flag to avoid
@ -709,11 +718,11 @@ class Main {
allowSet; allowSet;
constructor() { constructor() {
this.allowSet = false; this.allowSet = false;
this.testUnknownDevice = false;
this.deviceOptions = document.getElementById('device-options'); this.deviceOptions = document.getElementById('device-options');
this.settings = {}; this.settings = {};
this.setters = {}; this.selectorMap = {};
// window.addEventListener('unload', () => this.exit()); // window.addEventListener('unload', () => this.exit());
this.promise = new Promise(async (resolve, reject) => {
/** @type {HTMLIFrameElement} */ /** @type {HTMLIFrameElement} */
let iframe = document.getElementById('frame'); let iframe = document.getElementById('frame');
iframe.addEventListener('load', () => { iframe.addEventListener('load', () => {
@ -729,58 +738,80 @@ class Main {
}); });
applyI18nToDom(iframe.contentDocument); applyI18nToDom(iframe.contentDocument);
}); });
function apply_class(class_name, value) {
[document, iframe.contentDocument].forEach(d => value ?
d.body.classList.add(class_name) :
d.body.classList.remove(class_name)
);
}
await this.loadConfig();
await initI18n(this.settings['language']);
this.canvasController = new CanvasController(); this.canvasController = new CanvasController();
putEvent('#button-exit' , 'click', () => this.exit(false), this);
putEvent('#button-print' , 'click', this.print, this); Ev.put('#button-exit' , 'click', () => this.exit(false), this);
putEvent('#device-refresh' , 'click', this.searchDevices, this); Ev.put('#button-print' , 'click', this.print, this);
putEvent('#button-exit' , 'contextmenu', (event) => (event.preventDefault(), this.exit(true)), this); Ev.put('#device-refresh' , 'click', this.searchDevices, this);
putEvent('#set-accessibility', 'click', () => Dialog.alert('#accessibility')); Ev.put('#button-exit' , 'contextmenu', (event) => (event.preventDefault(), this.exit(true)), this);
this.attachSetter('#device-options', 'input', 'printer', Ev.put('#set-accessibility' , 'click', () => Dialog.alert('#accessibility'));
Ev.put('a[target="frame"]', 'click', () => Dialog.alert('#frame'));
Ev.put('#test-unknown-device' , 'click', () => {
Dialog.alert(i18n('now-will-scan-for-all-bluetooth-devices-nearby'), null, true);
this.testUnknownDevice = true;
Panel('panel-print');
Hider.show('print');
this.searchDevices();
});
this.conf('#device-options', 'change', 'printer',
(value) => callApi('/connect', { device: value }) (value) => callApi('/connect', { device: value })
); );
putEvent('a[target="frame"]', 'click', () => Dialog.alert('#frame')); this.conf('[name="algo"]' , 'change', 'mono_algorithm',
this.attachSetter('[name="scan-time"]' , 'change', 'scan_timeout');
this.attachSetter('[name="rotate"]' , 'change', 'rotate');
this.attachSetter('input[name="algo"]' , 'change', 'mono_algorithm',
(value) => this.settings['text_mode'] = (value === 'algo-direct') (value) => this.settings['text_mode'] = (value === 'algo-direct')
); );
this.attachSetter('[name="transparent-as-white"]', 'change', 'transparent_as_white'); this.conf('[name="dry-run"]', 'change', 'dry_run',
this.attachSetter('[name="wrap-words-by-spaces"]', 'change', 'wrap_by_space');
this.attachSetter('[name="dry-run"]', 'change', 'dry_run',
(checked) => checked && Notice.note('dry-run-test-print-process-only') (checked) => checked && Notice.note('dry-run-test-print-process-only')
); );
this.attachSetter('[name="no-animation"]', 'change', 'no_animation',
(checked) => apply_class('no-animation', checked) const apply_class = (class_name, value) => {
for (let d of [document, iframe.contentDocument])
value ? d.body.classList.add(class_name)
: d.body.classList.remove(class_name);
};
// const toggle_class = (class_name) => (value) => apply_class(class_name, value);
const conf = (...keys) => {
for (let key of keys)
this.conf(
'[name="' + key + '"]', 'change',
key.replace(/-/g, '_')
); );
this.attachSetter('[name="large-font"]', 'change', 'large_font', };
(checked) => apply_class('large-font', checked) const conf_class = (...keys) => {
for (let key of keys)
this.conf(
'[name="' + key + '"]', 'change',
key.replace(/-/g, '_'),
value => apply_class(key, value)
); );
this.attachSetter('[name="force-rtl"]', 'change', 'force_rtl', };
(checked) => apply_class('force-rtl', checked)
conf(
'scan-time',
'rotate',
'transparent-as-white',
'wrap-words-by-spaces',
'threshold',
'energy',
'quality',
'flip'
); );
this.attachSetter('[name="dark-theme"]', 'change', 'dark_theme', conf_class(
(checked) => apply_class('dark', checked) 'no-animation',
'large-font',
'force-rtl',
'dark-theme',
'high-contrast'
); );
this.attachSetter('[name="high-contrast"]', 'change', 'high_contrast',
(checked) => apply_class('high-contrast', checked) this.promise = new Promise(async (resolve, reject) => {
); await this.loadConfig();
this.attachSetter('[name="threshold"]' , 'change', 'threshold'); await initI18n(this.settings['language']);
this.attachSetter('[name="energy"]' , 'change', 'energy');
this.attachSetter('[name="quality"]' , 'change', 'quality');
this.attachSetter('[name="flip"]' , 'change', 'flip');
await this.activateConfig(); await this.activateConfig();
// one exception // one exception
this.attachSetter('#select-language', 'change', 'language'); this.conf('#select-language', 'change', 'language');
if (this.settings['is_android']) { if (this.settings['is_android']) {
document.body.classList.add('android'); document.body.classList.add('android');
@ -789,6 +820,7 @@ class Main {
let select = document.getElementById('select-language'); let select = document.getElementById('select-language');
Array.from(select.children).forEach(e => { Array.from(select.children).forEach(e => {
e.selected = false; e.selected = false;
e.addEventListener('click', () => this.set({ language: e.value }));
div.appendChild(e); div.appendChild(e);
}); });
div.id = 'select-language'; div.id = 'select-language';
@ -824,30 +856,9 @@ class Main {
if (this.settings['first_run']) if (this.settings['first_run'])
Dialog.alert('#accessibility', () => this.set({ first_run: false })); Dialog.alert('#accessibility', () => this.set({ first_run: false }));
for (let key in this.settings) { for (let key in this.settings) {
if (this.selectorMap[key] === undefined) continue;
let value = this.settings[key]; let value = this.settings[key];
if (this.setters[key] === undefined) continue; Ev.dispatch(this.selectorMap[key], 'change', { value: value });
// 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;
case 'text':
case 'number':
case 'range':
element.value = value;
break;
default:
if (element.value === value)
element.click();
}
element.dispatchEvent(new Event('change'));
});
} }
this.allowSet = true; this.allowSet = true;
await this.set(this.settings); await this.set(this.settings);
@ -857,8 +868,9 @@ class Main {
* @param {string} attribute The setting to change, i.e. `this.setting[attribute] = value;` * @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 * @param {(value: any) => any} callback Optional additinal post-procedure to call, with a *reasonable* value as parameter
*/ */
attachSetter(selector, type, attribute, callback) { conf(selector, type, attribute, callback) {
this.setters[attribute] = putEvent(selector, type, event => { this.selectorMap[attribute] = selector;
Ev.put(selector, type, event => {
event.stopPropagation(); event.stopPropagation();
event.cancelBubble = true; event.cancelBubble = true;
let input = event.currentTarget; let input = event.currentTarget;
@ -918,10 +930,12 @@ class Main {
} }
async searchDevices() { async searchDevices() {
Notice.wait('scanning-for-devices'); Notice.wait('scanning-for-devices');
let search_result = await callApi('/devices', null, this.handleBluetoothProblem); let search_result = await callApi('/devices', {
everything: this.testUnknownDevice
}, this.handleBluetoothProblem);
if (search_result === null) return false; if (search_result === null) return false;
let devices = search_result.devices; let devices = search_result.devices;
Array.from(this.deviceOptions.children).forEach(e => e.remove()); for (let e of this.deviceOptions.children) e.remove();
if (devices.length === 0) { if (devices.length === 0) {
Notice.note('no-available-devices-found'); Notice.note('no-available-devices-found');
hint('#device-refresh'); hint('#device-refresh');
@ -929,13 +943,13 @@ class Main {
} }
Notice.note('found-0-available-devices', [devices.length]); Notice.note('found-0-available-devices', [devices.length]);
hint('#insert-picture'); hint('#insert-picture');
devices.forEach(device => { for (let device of devices) {
let option = document.createElement('option'); let option = document.createElement('option');
option.value = `${device.name},${device.address}`; option.value = `${device.name},${device.address}`;
option.innerText = `${device.name}-${device.address.slice(3, 5)}${device.address.slice(0, 2)}`; option.innerText = `${device.name}-${device.address.slice(3, 5)}${device.address.slice(0, 2)}`;
this.deviceOptions.appendChild(option); this.deviceOptions.appendChild(option);
}); }
this.deviceOptions.dispatchEvent(new Event('input')); Ev.dispatch('#device-options', 'change');
return true; return true;
} }
async print() { async print() {