diff --git a/README.md b/README.md index f0b2ddc..cc81ade 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ $ pip install -r requirements.txt ```bash $ ./print.py --help usage: print.py [-h] [-l {debug,info,warn,error}] [-b {mean-threshold,floyd-steinberg,atkinson,halftone,none}] [-s] [-d DEVICE] [-e ENERGY] - filename + [-S SPEED] filename prints an image on your cat thermal printer @@ -40,8 +40,64 @@ options: services. -e ENERGY, --energy ENERGY Thermal energy. Between 0x0000 (light) and 0xffff (darker, default). + -S SPEED, --speed SPEED + Print speed. Higher values print slower and darker. The official app uses 10 for the SC03h. ``` +# Desktop app (AppImage) + +There is also a GTK 4 desktop app (in Spanish) in `catprinter_gui/`. It +stays connected to the printer, shows its battery level and prints images, +PDFs, office documents (through LibreOffice) and text files with a preview +of the exact dots that will be printed. + +It runs on the system's Python and GTK, so on Debian/Ubuntu you need: + +```bash +$ sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-4.0 gir1.2-adw-1 poppler-utils +``` + +Build the AppImage (about 1 MB) and run it: + +```bash +$ ./appimage/build.sh +$ ./dist/CatPrinter-*-x86_64.AppImage +``` + +From the app menu, "Integrar con Archivos y el menú de apps" (or +`--instalar` on the command line) copies the AppImage to `~/.local/bin` and +adds it to the application menu, to "Open With" and to the context menu of +Files. For a top-level entry in that context menu, also install +`python3-nautilus` and restart Files with `nautilus -q`. + +# Web app + +`docs/` contains a web app (in Spanish) that prints from the browser through +Web Bluetooth, with the same processing and print settings as the desktop +app. It is a PWA: it can be installed, works offline once loaded, appears in +the Android share sheet and, when installed on the desktop, in "Open With". + +Publish it with GitHub Pages (Settings → Pages → Deploy from a branch → +`main`, `/docs`). Web Bluetooth needs Chrome, Edge or Opera on Windows, +macOS, Android or ChromeOS; on other browsers the page explains the +alternatives. To try it locally, serve `docs/` over `http://localhost`. + +# Dual-mode printers (SC03h) + +Some printers, like the SC03h, also offer classic Bluetooth. BlueZ may then +try to connect over classic Bluetooth and fail with +`br-connection-profile-unavailable`. To fix it: + +- Don't pair the printer in the system's Bluetooth settings. If you did, + remove it there: these printers don't need pairing. +- With BlueZ 5.80 or newer, set `Experimental = true` in the `[General]` + section of `/etc/bluetooth/main.conf` and run + `sudo systemctl restart bluetooth`. This lets catprinter ask BlueZ to use + Bluetooth Low Energy for the printer. + +If prints come out blank, charge the printer: with a low battery it feeds +the paper but doesn't heat the print head. + # Example ```bash % ./print.py --show-preview test.png diff --git a/appimage/AppRun b/appimage/AppRun new file mode 100755 index 0000000..68624eb --- /dev/null +++ b/appimage/AppRun @@ -0,0 +1,22 @@ +#!/bin/sh +# Runs the app with the system's Python and GTK; the AppImage only bundles +# pure Python code. +HERE="$(dirname "$(readlink -f "$0")")" + +fail() { + if command -v zenity >/dev/null 2>&1; then + zenity --error --title="Impresora térmica" --text="$1" 2>/dev/null + elif command -v notify-send >/dev/null 2>&1; then + notify-send "Impresora térmica" "$1" + fi + echo "$1" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || fail "Falta Python 3. Instálalo con: sudo apt install python3" +python3 -c 'import gi, cairo; gi.require_version("Gtk", "4.0"); gi.require_version("Adw", "1")' 2>/dev/null || + fail "Faltan bibliotecas de GTK. Instálalas con: +sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-4.0 gir1.2-adw-1" + +export PYTHONPATH="$HERE/usr/share/catprinter:$HERE/usr/share/catprinter/vendor" +exec python3 -s -m catprinter_gui "$@" diff --git a/appimage/build.sh b/appimage/build.sh new file mode 100755 index 0000000..04fcf1a --- /dev/null +++ b/appimage/build.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Builds dist/CatPrinter--x86_64.AppImage. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="$ROOT/build/appimage" +APPDIR="$BUILD/AppDir" +APP_ID=io.github.catprinter.Gui +APPIMAGETOOL_VERSION=1.9.1 +TOOL="$BUILD/tools/appimagetool-$APPIMAGETOOL_VERSION-x86_64.AppImage" +TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage" +VERSION="$(cd "$ROOT" && python3 -c 'import catprinter_gui; print(catprinter_gui.VERSION)')" +OUTPUT="$ROOT/dist/CatPrinter-$VERSION-x86_64.AppImage" + +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/share/catprinter" "$BUILD/tools" "$ROOT/dist" + +# App code. img.py needs OpenCV and is only used by the command line tool. +for pkg in catprinter catprinter_gui; do + cp -r "$ROOT/$pkg" "$APPDIR/usr/share/catprinter/" +done +find "$APPDIR" -name __pycache__ -prune -exec rm -rf {} + +rm -f "$APPDIR/usr/share/catprinter/catprinter/img.py" + +python3 -m pip install --quiet --no-compile \ + --target "$APPDIR/usr/share/catprinter/vendor" -r "$ROOT/appimage/requirements.txt" +rm -rf "$APPDIR/usr/share/catprinter/vendor/bin" +if find "$APPDIR/usr/share/catprinter/vendor" -name '*.so' | grep -q .; then + echo "Error: bundled dependencies must be pure Python" >&2 + exit 1 +fi + +cp "$ROOT/appimage/AppRun" "$APPDIR/AppRun" +cp "$ROOT/catprinter_gui/data/$APP_ID.svg" "$APPDIR/$APP_ID.svg" +ln -sf "$APP_ID.svg" "$APPDIR/.DirIcon" +mkdir -p "$APPDIR/usr/share/icons/hicolor/scalable/apps" "$APPDIR/usr/share/applications" +cp "$ROOT/catprinter_gui/data/$APP_ID.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/" +cat > "$APPDIR/$APP_ID.desktop" < Optional[BLEDevice]: + '''Auto-discovers a printer, returning None if none is found.''' + + def filter_fn(device: BLEDevice, adv_data: AdvertisementData): + return any(uuid in adv_data.service_uuids for uuid in POSSIBLE_SERVICE_UUIDS) + + return await BleakScanner.find_device_by_filter(filter_fn, timeout=timeout) + + def chunkify(data, chunk_size): return (data[i : i + chunk_size] for i in range(0, len(data), chunk_size)) @@ -78,53 +100,184 @@ async def get_device_address(device: Optional[str]): return await scan(device, timeout=SCAN_TIMEOUT_S) -def notification_receiver_factory(event): - def notification_receiver(sender, data): - logger.debug(f"📡 Received notification: {data}") - if data == PRINTER_READY_NOTIFICATION: - event.set() +async def prefer_le_bearer(address): + '''Asks BlueZ to connect to the printer over Bluetooth Low Energy. - return notification_receiver + Dual-mode printers (e.g. SC03h) also advertise classic Bluetooth (SPP), and + BlueZ then tries a BR/EDR connection, which fails with + "br-connection-profile-unavailable". The PreferredBearer property needs + BlueZ >= 5.80 with "Experimental = true" in /etc/bluetooth/main.conf; it is + silently skipped otherwise. + ''' + if BleakClientBlueZDBus is None or address is None: + return + from dbus_next import BusType, Message, MessageType, Variant + from dbus_next.aio import MessageBus + if isinstance(address, BLEDevice): + path = address.details["path"] + else: + path = "/org/bluez/hci0/dev_" + address.upper().replace(":", "_") + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + try: + reply = await bus.call( + Message( + destination="org.bluez", + path=path, + interface="org.freedesktop.DBus.Properties", + member="Set", + signature="ssv", + body=["org.bluez.Device1", "PreferredBearer", Variant("s", "le")], + ) + ) + if reply.message_type == MessageType.ERROR: + logger.debug(f"Could not set PreferredBearer: {reply.error_name}") + else: + logger.debug("Set PreferredBearer=le") + finally: + bus.disconnect() -async def wait_for_printer_ready(event): - logger.info("⏳ Done printing. Waiting for printer to be ready...") - await event.wait() - logger.info("✅ Printer is ready, disconnecting...") +async def connect(address, disconnected_callback=None): + '''Connects to the printer, retrying if BlueZ picks the classic (BR/EDR) + bearer of a dual-mode printer anyway.''' + for attempt in range(CONNECT_ATTEMPTS): + await prefer_le_bearer(address) + logger.info(f"⏳ Connecting to {address}...") + client = BleakClient(address, disconnected_callback=disconnected_callback) + try: + await client.connect() + return client + except Exception as e: + if "br-connection-profile-unavailable" not in str(e) or attempt == CONNECT_ATTEMPTS - 1: + raise + logger.info("⚠️ BlueZ tried classic Bluetooth, retrying over LE...") + await asyncio.sleep(1) -async def run_ble(data, device: Optional[str]): - try: - address = await get_device_address(device) - except RuntimeError as e: - logger.error(f"🛑 {e}") - return - logger.info(f"⏳ Connecting to {address}...") - async with BleakClient(address) as client: - # XXX: BlueZ incorrectly reports a fixed MTU of 23; force MTU negotiation manually. - # https://bleak.readthedocs.io/en/latest/api/client.html#bleak.BleakClient.mtu_size - # Only use this library on Linux, not MacOS - if BleakClientBlueZDBus and isinstance(client, BleakClientBlueZDBus): - await client._acquire_mtu() - logger.info(f"✅ Connected: {client.is_connected}; MTU: {client.mtu_size}") - chunk_size = client.mtu_size - 3 - event = asyncio.Event() +class PrinterConnection: + '''A connection to the printer that can stay open between prints.''' - receive_notification = notification_receiver_factory(event) + def __init__(self, address, on_disconnect=None): + self.address = address + self.on_disconnect = on_disconnect + self.client = None + self.status = PrinterStatus() + self.can_send = asyncio.Event() + self.can_send.set() + # Set when the printer answers a state query, which it does once it has + # processed everything sent before it. + self.state_received = asyncio.Event() + # Prints and status queries must not interleave. + self.lock = asyncio.Lock() - await client.start_notify(RX_CHARACTERISTIC_UUID, receive_notification) + @property + def is_connected(self): + return self.client is not None and self.client.is_connected - logger.info( - f"⏳ Sending {len(data)} bytes of data in chunks of {chunk_size} bytes..." - ) - for i, chunk in enumerate(chunkify(data, chunk_size)): - await client.write_gatt_char(TX_CHARACTERISTIC_UUID, chunk) - await asyncio.sleep(WAIT_AFTER_EACH_CHUNK_S) + async def open(self): + def disconnected(_client): + logger.info("🔌 Printer disconnected") + self.client = None + if self.on_disconnect: + self.on_disconnect() + client = await connect(self.address, disconnected) try: - await asyncio.wait_for( - wait_for_printer_ready(event), timeout=WAIT_FOR_PRINTER_DONE_TIMEOUT + # XXX: BlueZ incorrectly reports a fixed MTU of 23; force MTU negotiation manually. + # https://bleak.readthedocs.io/en/latest/api/client.html#bleak.BleakClient.mtu_size + # Only use this library on Linux, not MacOS + if BleakClientBlueZDBus and isinstance(client, BleakClientBlueZDBus): + await client._acquire_mtu() + await client.start_notify(RX_CHARACTERISTIC_UUID, self._on_notification) + except Exception: + await client.disconnect() + raise + self.client = client + logger.info(f"✅ Connected: {client.is_connected}; MTU: {client.mtu_size}") + + async def close(self): + client, self.client = self.client, None + if client is not None: + # Closing on purpose: don't report it as a lost connection. + client.set_disconnected_callback(None) + await client.disconnect() + + def _on_notification(self, _sender, data): + logger.debug(f"📡 Received notification: {bytes(data).hex(' ')}") + parse_notification(data, self.status) + if data == PRINTER_READY_NOTIFICATION: + self.can_send.set() + elif data == PRINTER_BUSY_NOTIFICATION: + self.can_send.clear() + elif len(data) > 2 and data[2] == CMD_DEV_STATE: + self.state_received.set() + + async def query_status(self) -> PrinterStatus: + '''Asks the printer for its state and battery level.''' + async with self.lock: + for cmd in (CMD_GET_DEV_STATE, CMD_GET_BATTERY): + await self.client.write_gatt_char(TX_CHARACTERISTIC_UUID, cmd) + await asyncio.sleep(STATUS_WAIT_S) + logger.info(f"🔋 Printer status: {self.status}") + return self.status + + async def send(self, data, progress=None) -> PrinterStatus: + '''Sends data generated by cmds_print_img() and waits until the printer + has processed it. progress, if given, is called with the fraction of + data sent so far.''' + async with self.lock: + chunk_size = min(self.client.mtu_size, MAX_PACKET_SIZE) - 3 + chunks = list(chunkify(data, chunk_size)) + self.can_send.set() + logger.info( + f"⏳ Sending {len(data)} bytes of data in chunks of {chunk_size} bytes..." ) - except asyncio.TimeoutError: - logger.error("🛑 Timed out while waiting for printer done event. Exiting.") + for i, chunk in enumerate(chunks): + if i == len(chunks) - 1: + # The data ends with a state query; its answer means "done". + self.state_received.clear() + await asyncio.wait_for( + self.can_send.wait(), timeout=WAIT_FOR_PRINTER_DONE_TIMEOUT) + await self.client.write_gatt_char(TX_CHARACTERISTIC_UUID, chunk) + await asyncio.sleep(WAIT_AFTER_EACH_CHUNK_S) + if progress: + progress((i + 1) / len(chunks)) + + logger.info("⏳ Done sending. Waiting for the printer to finish...") + try: + await asyncio.wait_for( + self.state_received.wait(), timeout=WAIT_FOR_PRINTER_DONE_TIMEOUT) + logger.info("✅ Printer is done.") + except asyncio.TimeoutError: + logger.error("🛑 Timed out while waiting for printer done event.") + return self.status + + +async def query_status(address) -> PrinterStatus: + '''Connects briefly to ask the printer for its state and battery level.''' + connection = PrinterConnection(address) + await connection.open() + try: + return await connection.query_status() + finally: + await connection.close() + + +async def send_data(address, data, progress=None) -> PrinterStatus: + '''Connects, sends data to the printer and disconnects.''' + connection = PrinterConnection(address) + await connection.open() + try: + return await connection.send(data, progress) + finally: + await connection.close() + + +async def run_ble(data, device: Optional[str]): + try: + address = await get_device_address(device) + except RuntimeError as e: + logger.error(f"🛑 {e}") + return + await send_data(address, data) diff --git a/catprinter/cmds.py b/catprinter/cmds.py index c3a813b..6bbc180 100644 --- a/catprinter/cmds.py +++ b/catprinter/cmds.py @@ -23,6 +23,11 @@ def bs(lst): 81, 120, -93, 0, 1, 0, 0, 0, -1 ]) +# Makes the printer apply the configured energy and speed. +CMD_UPDATE_DEVICE = bs([81, 120, -87, 0, 1, 0, 0, 0, -1]) + +CMD_GET_BATTERY = bs([81, 120, -70, 0, 1, 0, 0, 0, -1]) + CMD_SET_QUALITY_200_DPI = bs([81, 120, -92, 0, 1, 0, 50, -98, -1]) CMD_GET_DEV_INFO = bs([81, 120, -88, 0, 1, 0, 0, 0, -1]) @@ -65,53 +70,40 @@ def chk_sum(b_arr, i, i2): return b2 -def cmd_feed_paper(how_much): +def cmd_set_energy(val): + '''Thermal energy, sent little-endian like the official app does. The + official app uses values between ~5000 and ~14000 depending on the model + and on the selected concentration.''' b_arr = bs([ 81, 120, - -67, + -81, 0, - 1, + 2, 0, - how_much & 0xff, + val & 0xff, + (val >> 8) & 0xff, 0, 0xff, ]) - b_arr[7] = chk_sum(b_arr, 6, 1) + b_arr[8] = chk_sum(b_arr, 6, 2) return bs(b_arr) -def cmd_set_energy(val): +def cmd_set_speed(val): + '''Print speed. Higher values print slower, which gives the thermal head + more time to heat up and results in darker prints.''' b_arr = bs([ 81, 120, - -81, + -67, 0, - 2, + 1, 0, - (val >> 8) & 0xff, val & 0xff, 0, 0xff, ]) - b_arr[8] = chk_sum(b_arr, 6, 2) - return bs(b_arr) - - -def cmd_apply_energy(): - b_arr = bs( - [ - 81, - 120, - -66, - 0, - 1, - 0, - 1, - 0, - 0xff, - ] - ) b_arr[7] = chk_sum(b_arr, 6, 1) return bs(b_arr) @@ -185,17 +177,33 @@ def cmd_print_row(img_row): return b_arr -def cmds_print_img(img, energy: int = 0xffff): +# The official app re-sends the speed every this many rows. +SPEED_RESEND_ROWS = 200 + +# Speed the official app sets before feeding the paper at the end. +FEED_SPEED = 25 + + +def cmds_print_img(img, energy: int = 0xffff, speed: int = None, text_mode: bool = True): + '''Builds the commands to print img, a sequence of rows of PRINT_WIDTH + booleans (True = black). Follows the same sequence as the official app. + text_mode selects the printer's text mode (sharp) instead of image mode.''' data = \ CMD_GET_DEV_STATE + \ CMD_SET_QUALITY_200_DPI + \ cmd_set_energy(energy) + \ - cmd_apply_energy() + \ + (CMD_PRINT_TEXT if text_mode else CMD_PRINT_IMG) + if speed is not None: + data += cmd_set_speed(speed) + data += \ + CMD_UPDATE_DEVICE + \ CMD_LATTICE_START - for row in img: + for i, row in enumerate(img): + if speed is not None and i and i % SPEED_RESEND_ROWS == 0: + data += cmd_set_speed(speed) data += cmd_print_row(row) data += \ - cmd_feed_paper(25) + \ + cmd_set_speed(FEED_SPEED) + \ CMD_SET_PAPER + \ CMD_SET_PAPER + \ CMD_SET_PAPER + \ diff --git a/catprinter/status.py b/catprinter/status.py new file mode 100644 index 0000000..ae92761 --- /dev/null +++ b/catprinter/status.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import Optional + +# Notifications that carry the printer's state. +CMD_DEV_STATE = 0xa3 +CMD_BATTERY = 0xba + + +@dataclass +class PrinterStatus: + battery: Optional[int] = None + no_paper: bool = False + cover_open: bool = False + overheat: bool = False + low_battery: bool = False + charging: bool = False + + +def parse_notification(data, status): + '''Updates status from a printer notification. Notifications look like + 51 78 01 ff. + + The state flags match the ones the official iPrint app checks. The state + answer of an SC03h looks like 51 78 a3 01 03 00 ff. + ''' + if len(data) < 8 or data[0:2] != b"\x51\x78": + return + cmd = data[2] + length = data[4] | data[5] << 8 + payload = data[6:6 + length] + if not payload: + return + if cmd == CMD_DEV_STATE: + flags = payload[0] + status.no_paper = bool(flags & 0x01) + status.cover_open = bool(flags & 0x02) + status.overheat = bool(flags & 0x04) + status.low_battery = bool(flags & 0x08) + status.charging = bool(flags & 0x10) + if len(payload) >= 2 and payload[1] <= 100: + status.battery = payload[1] + elif cmd == CMD_BATTERY and payload[0] <= 100: + status.battery = payload[0] diff --git a/catprinter_gui/__init__.py b/catprinter_gui/__init__.py new file mode 100644 index 0000000..30a8315 --- /dev/null +++ b/catprinter_gui/__init__.py @@ -0,0 +1,7 @@ +"""GTK 4 app to print images, PDFs, documents and text on cat printers.""" +import os + +APP_ID = "io.github.catprinter.Gui" +APP_NAME = "Impresora térmica" +VERSION = "1.0.0" +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") diff --git a/catprinter_gui/__main__.py b/catprinter_gui/__main__.py new file mode 100644 index 0000000..6b7c590 --- /dev/null +++ b/catprinter_gui/__main__.py @@ -0,0 +1,30 @@ +import sys + +USAGE = """Uso: catprinter [ARCHIVOS…] + catprinter --instalar Integra la app con el escritorio y Archivos + catprinter --desinstalar Quita la integración""" + + +def main(): + args = sys.argv[1:] + if args and args[0] in ("-h", "--help", "--ayuda"): + print(USAGE) + return 0 + if args and args[0] in ("--instalar", "--install", "--desinstalar", "--uninstall"): + from catprinter_gui import integration + try: + if args[0] in ("--instalar", "--install"): + print(integration.install()) + else: + print(integration.uninstall()) + except RuntimeError as e: + print(e, file=sys.stderr) + return 1 + return 0 + + from catprinter_gui.application import Application + return Application().run(sys.argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/catprinter_gui/application.py b/catprinter_gui/application.py new file mode 100644 index 0000000..3d376a3 --- /dev/null +++ b/catprinter_gui/application.py @@ -0,0 +1,68 @@ +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, Gio, Gtk # noqa: E402 + +from catprinter_gui import APP_ID, APP_NAME, DATA_DIR, VERSION, integration # noqa: E402 +from catprinter_gui.window import Window # noqa: E402 + + +class Application(Adw.Application): + def __init__(self): + super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.HANDLES_OPEN) + self.window = None + + def do_startup(self): + Adw.Application.do_startup(self) + # The icon ships with the app, so it shows even when not installed. + theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) + theme.add_search_path(DATA_DIR) + for name, callback in (("install", self.on_install), + ("uninstall", self.on_uninstall), + ("about", self.on_about)): + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + self.set_accels_for_action("window.close", ["w", "q"]) + + def get_window(self): + if self.window is None: + self.window = Window(self) + return self.window + + def do_activate(self): + self.get_window().present() + + def do_open(self, files, n_files, hint): + window = self.get_window() + paths = [f.get_path() for f in files if f.get_path()] + if paths: + window.load_files(paths) + window.present() + + def message(self, heading, body): + dialog = Adw.AlertDialog(heading=heading, body=body) + dialog.add_response("ok", "Aceptar") + dialog.present(self.window) + + def on_install(self, *_): + try: + self.message("Integración lista", integration.install()) + except Exception as e: + self.message("No se pudo integrar", str(e)) + + def on_uninstall(self, *_): + self.message("Integración eliminada", integration.uninstall()) + + def on_about(self, *_): + Adw.AboutDialog( + application_name=APP_NAME, + application_icon=APP_ID, + version=VERSION, + developer_name="Gabriel Cortés", + comments="Imprime imágenes, PDF, documentos y texto en impresoras " + "térmicas Bluetooth (GT01, GB0x, SC03h y similares).", + website="https://github.com/TetradogOther/catprinter", + license_type=Gtk.License.MIT_X11, + ).present(self.window) diff --git a/catprinter_gui/data/io.github.catprinter.Gui.svg b/catprinter_gui/data/io.github.catprinter.Gui.svg new file mode 100644 index 0000000..3210c88 --- /dev/null +++ b/catprinter_gui/data/io.github.catprinter.Gui.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/catprinter_gui/data/nautilus_extension.py b/catprinter_gui/data/nautilus_extension.py new file mode 100644 index 0000000..dd5acaf --- /dev/null +++ b/catprinter_gui/data/nautilus_extension.py @@ -0,0 +1,36 @@ +# Installed by the catprinter AppImage. Adds "Imprimir en impresora térmica" +# to the context menu of Files. Requires: sudo apt install python3-nautilus +import subprocess + +from gi.repository import GObject, Nautilus + +EXEC = "@EXEC@" +SUPPORTED = ( + "image/", "text/", "application/pdf", "application/rtf", + "application/msword", "application/vnd.ms-", "application/vnd.oasis.opendocument.", + "application/vnd.openxmlformats-officedocument.", +) + + +class CatPrinterMenu(GObject.GObject, Nautilus.MenuProvider): + def get_file_items(self, files): + paths = [] + for f in files: + location = f.get_location() + path = location.get_path() if location else None + if f.is_directory() or not path or not f.get_mime_type().startswith(SUPPORTED): + return [] + paths.append(path) + if not paths: + return [] + item = Nautilus.MenuItem( + name="CatPrinter::print", + label="Imprimir en impresora térmica", + icon="io.github.catprinter.Gui", + ) + item.connect("activate", lambda *_: subprocess.Popen( + [EXEC, *paths], start_new_session=True)) + return [item] + + def get_background_items(self, *args): + return [] diff --git a/catprinter_gui/integration.py b/catprinter_gui/integration.py new file mode 100644 index 0000000..c8d4d2c --- /dev/null +++ b/catprinter_gui/integration.py @@ -0,0 +1,118 @@ +"""Desktop integration for the AppImage: application menu, "Open With" and +the context menu of Files (Nautilus).""" +import glob +import os +import shutil +import subprocess + +from catprinter_gui import APP_ID, APP_NAME, DATA_DIR +from catprinter_gui.render import MIME_TYPES + +HOME = os.path.expanduser("~") +DATA_HOME = os.environ.get("XDG_DATA_HOME") or os.path.join(HOME, ".local", "share") + +APPIMAGE_DEST = os.path.join(HOME, ".local", "bin", "CatPrinter.AppImage") +DESKTOP_FILE = os.path.join(DATA_HOME, "applications", f"{APP_ID}.desktop") +ICON_FILE = os.path.join(DATA_HOME, "icons", "hicolor", "scalable", "apps", f"{APP_ID}.svg") +NAUTILUS_SCRIPT = os.path.join(DATA_HOME, "nautilus", "scripts", "Imprimir en impresora térmica") +NAUTILUS_EXTENSION = os.path.join( + DATA_HOME, "nautilus-python", "extensions", "catprinter_nautilus.py") + +SCRIPT = """#!/bin/sh +# Installed by the catprinter AppImage. +set -f +IFS=' +' +[ -n "$NAUTILUS_SCRIPT_SELECTED_FILE_PATHS" ] && set -- $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS +exec "{exec}" "$@" +""" + +DESKTOP = """[Desktop Entry] +Type=Application +Name={name} +GenericName=Impresora térmica Bluetooth +Comment=Imprime imágenes, PDF, documentos y texto en tu impresora térmica +Exec="{exec}" %F +Icon={app_id} +Terminal=false +Categories=Graphics;Utility; +Keywords=printer;thermal;cat;impresora;térmica;bluetooth; +MimeType={mime}; +StartupNotify=true +StartupWMClass={app_id} +""" + + +def is_appimage(): + return bool(os.environ.get("APPIMAGE")) + + +def nautilus_python_available(): + return bool(glob.glob("/usr/lib/*/nautilus/extensions-4/libnautilus-python.so")) + + +def is_installed(): + return os.path.exists(DESKTOP_FILE) + + +def _write(path, content, executable=False): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(content) + if executable: + os.chmod(path, 0o755) + + +def install(): + '''Copies the AppImage to ~/.local/bin and installs the launchers. + Returns a summary for the user.''' + source = os.environ.get("APPIMAGE") + if not source: + raise RuntimeError("La integración solo está disponible al ejecutar la AppImage.") + os.makedirs(os.path.dirname(APPIMAGE_DEST), exist_ok=True) + if os.path.realpath(source) != os.path.realpath(APPIMAGE_DEST): + tmp = APPIMAGE_DEST + ".new" + shutil.copy2(source, tmp) + os.chmod(tmp, 0o755) + os.replace(tmp, APPIMAGE_DEST) + + os.makedirs(os.path.dirname(ICON_FILE), exist_ok=True) + shutil.copy(os.path.join(DATA_DIR, f"{APP_ID}.svg"), ICON_FILE) + _write(DESKTOP_FILE, DESKTOP.format( + name=APP_NAME, exec=APPIMAGE_DEST, app_id=APP_ID, mime=";".join(MIME_TYPES))) + _write(NAUTILUS_SCRIPT, SCRIPT.format(name=APP_NAME, exec=APPIMAGE_DEST), executable=True) + with open(os.path.join(DATA_DIR, "nautilus_extension.py")) as f: + _write(NAUTILUS_EXTENSION, f.read().replace("@EXEC@", APPIMAGE_DEST)) + + for cmd in (["update-desktop-database", os.path.dirname(DESKTOP_FILE)], + ["gtk-update-icon-cache", "-qtf", os.path.join(DATA_HOME, "icons", "hicolor")]): + try: + subprocess.run(cmd, capture_output=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): + pass + + lines = [ + f"Instalado en {APPIMAGE_DEST}", + "Aparece en el menú de aplicaciones y en «Abrir con» de Archivos.", + ] + if nautilus_python_available(): + lines.append("Menú contextual de Archivos: «Imprimir en impresora térmica» " + "(reinicia Archivos con: nautilus -q).") + else: + lines.append("Menú contextual de Archivos: Scripts → «Imprimir en impresora térmica». " + "Para tenerlo directo en el menú: sudo apt install python3-nautilus && nautilus -q") + return "\n".join(lines) + + +def uninstall(): + for path in (DESKTOP_FILE, ICON_FILE, NAUTILUS_SCRIPT, NAUTILUS_EXTENSION, APPIMAGE_DEST): + try: + os.remove(path) + except FileNotFoundError: + pass + try: + subprocess.run(["update-desktop-database", os.path.dirname(DESKTOP_FILE)], + capture_output=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): + pass + return "Integración eliminada." diff --git a/catprinter_gui/printer.py b/catprinter_gui/printer.py new file mode 100644 index 0000000..b370344 --- /dev/null +++ b/catprinter_gui/printer.py @@ -0,0 +1,139 @@ +"""Printer access for the GUI: an asyncio loop in a background thread that +keeps a connection open to the printer while the app is running.""" +import asyncio +import logging +import logging.handlers +import os +import threading +from dataclasses import replace + +from gi.repository import GLib + +from catprinter import ble, logger +from catprinter.cmds import cmds_print_img + +SCAN_TIMEOUT_S = 6 + +# Everything the printer answers is logged here, which helps diagnosing models. +LOG_PATH = os.path.join( + os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), + "catprinter", "printer.log") +LOG_MAX_BYTES = 1024 * 1024 + + +def setup_logging(): + try: + os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + LOG_PATH, maxBytes=LOG_MAX_BYTES, backupCount=1) + except OSError: + return + handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + + +def friendly_error(error): + '''Turns a Bluetooth error into a message for the user.''' + text = str(error) + if "br-connection-profile-unavailable" in text: + return ("BlueZ intentó conectar por Bluetooth clásico. Si emparejaste la impresora en " + "Configuración → Bluetooth, elimínala de ahí: no hace falta emparejarla. " + "Si sigue fallando, activa «Experimental = true» en /etc/bluetooth/main.conf " + "y ejecuta: sudo systemctl restart bluetooth") + if "NotReady" in text or "No Bluetooth adapters" in text or "Powered" in text: + return "El Bluetooth está apagado. Actívalo en la configuración del sistema." + if isinstance(error, (asyncio.TimeoutError, TimeoutError)): + return "La impresora no respondió a tiempo. ¿Sigue encendida y cerca?" + if isinstance(error, PrinterNotConnected): + return "La impresora no está conectada." + if "not found" in text.lower() or "UnknownObject" in text: + return "No se encontró la impresora. ¿Está encendida?" + return f"Error de Bluetooth: {text or type(error).__name__}" + + +class PrinterNotConnected(Exception): + pass + + +class PrinterService: + '''Runs printer operations in the background. Every on_done callback is + called in the GTK thread as on_done(result, error).''' + + def __init__(self, on_disconnect): + setup_logging() + self.on_disconnect = on_disconnect + self.connection = None + self.loop = asyncio.new_event_loop() + threading.Thread(target=self.loop.run_forever, daemon=True).start() + + @property + def is_connected(self): + return self.connection is not None and self.connection.is_connected + + def scan(self, on_done): + '''Finds a printer. The result is a BLEDevice or None.''' + self._submit(ble.find_printer(SCAN_TIMEOUT_S), on_done) + + def connect(self, device, on_done): + '''Connects to device and reads its state. The result is a PrinterStatus.''' + async def job(): + await self._close() + + def lost(): + # Called by bleak in the asyncio thread. + if self.connection is connection: + self.connection = None + GLib.idle_add(self.on_disconnect) + + connection = ble.PrinterConnection(device, lost) + await connection.open() + self.connection = connection + return replace(await connection.query_status()) + + self._submit(job(), on_done) + + def query_status(self, on_done): + '''Reads the printer state. The result is a PrinterStatus.''' + async def job(): + return replace(await self._require_connection().query_status()) + + self._submit(job(), on_done) + + def print_rows(self, rows, energy, speed, text_mode, on_progress, on_done): + '''Prints rows of dots. on_progress is called with the fraction sent so + far. The result is the PrinterStatus after printing.''' + async def job(): + connection = self._require_connection() + data = await self.loop.run_in_executor( + None, lambda: cmds_print_img(rows, energy=energy, speed=speed, text_mode=text_mode)) + status = await connection.send(data, lambda p: GLib.idle_add(on_progress, p)) + return replace(status) + + self._submit(job(), on_done) + + def close(self): + '''Disconnects when the app closes, waiting a few seconds at most.''' + try: + asyncio.run_coroutine_threadsafe(self._close(), self.loop).result(timeout=3) + except Exception: + pass + + def _submit(self, coro, on_done): + future = asyncio.run_coroutine_threadsafe(coro, self.loop) + + def done(f): + error = f.exception() + GLib.idle_add(on_done, None if error else f.result(), error) + + future.add_done_callback(done) + + def _require_connection(self): + if not self.is_connected: + raise PrinterNotConnected() + return self.connection + + async def _close(self): + connection, self.connection = self.connection, None + if connection is not None: + await connection.close() diff --git a/catprinter_gui/process.py b/catprinter_gui/process.py new file mode 100644 index 0000000..cea8cf2 --- /dev/null +++ b/catprinter_gui/process.py @@ -0,0 +1,232 @@ +"""Turns pages into rows of black and white dots for the printer. + +Pure Python plus GdkPixbuf, so the app needs neither numpy nor OpenCV. +Grayscale images are bytearrays of width*height values from 0 to 255; dots +are bytearrays of 1 (black) and 0 (white). +""" +from dataclasses import dataclass + +import gi + +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import GdkPixbuf # noqa: E402 + +from catprinter.cmds import PRINT_WIDTH # noqa: E402 + +MODES = ("auto", "photo", "sharp", "atkinson") + +# Pixels darker than this count as content when cropping margins. +CROP_THRESHOLD = 220 +CROP_PADDING = 0.02 +TRIM_PADDING_ROWS = 8 + +_THRESHOLD_TABLE = bytes(1 if v < 128 else 0 for v in range(256)) +_CROP_TABLE = bytes(1 if v < CROP_THRESHOLD else 0 for v in range(256)) +_PREVIEW_TABLE = bytes([255, 0]) + bytes(254) + + +@dataclass +class Options: + mode: str = "auto" + brightness: int = 0 + contrast: int = 0 + rotate: bool = False + crop: bool = True + bold: bool = True + + +def flatten_scale(pb, width, height, interp=GdkPixbuf.InterpType.HYPER): + '''Scales pb to width x height over a white background, without alpha.''' + dest = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, width, height) + dest.fill(0xffffffff) + pb.composite(dest, 0, 0, width, height, 0, 0, + width / pb.get_width(), height / pb.get_height(), interp, 255) + return dest + + +def to_gray(pb): + w, h, rs = pb.get_width(), pb.get_height(), pb.get_rowstride() + data = pb.read_pixel_bytes().get_data() + out = bytearray(w * h) + for y in range(h): + row = data[y * rs:y * rs + w * 3] + out[y * w:(y + 1) * w] = bytes( + (77 * r + 150 * g + 29 * b) >> 8 + for r, g, b in zip(row[0::3], row[1::3], row[2::3])) + return out + + +def crop_to_content(pb): + '''Crops white margins so the content uses the whole paper width.''' + w, h = pb.get_width(), pb.get_height() + sw = min(w, 600) + sh = max(1, round(h * sw / w)) + gray = to_gray(flatten_scale(pb, sw, sh, GdkPixbuf.InterpType.BILINEAR)) + top = bottom = None + left, right = sw, -1 + for y in range(sh): + row = gray[y * sw:(y + 1) * sw].translate(_CROP_TABLE) + first = row.find(1) + if first < 0: + continue + if top is None: + top = y + bottom = y + left = min(left, first) + right = max(right, row.rfind(1)) + if top is None: + return pb + + sx, sy = w / sw, h / sh + pad = round(max(w, h) * CROP_PADDING) + x0 = max(0, int(left * sx) - pad) + y0 = max(0, int(top * sy) - pad) + x1 = min(w, int((right + 1) * sx) + pad) + y1 = min(h, int((bottom + 1) * sy) + pad) + return pb.new_subpixbuf(x0, y0, x1 - x0, y1 - y0) + + +def tone_table(brightness, contrast): + c = contrast * 2.55 + factor = 259 * (c + 255) / (255 * (259 - c)) + return bytes( + max(0, min(255, round(factor * (v - 128) + 128 + brightness * 2.55))) + for v in range(256)) + + +def floyd_steinberg(gray, w, h): + px = list(gray) + out = bytearray(w * h) + for y in range(h): + base = y * w + has_next = y < h - 1 + for x in range(w): + i = base + x + old = px[i] + if old < 128: + out[i] = 1 + err = old + else: + err = old - 255 + if not err: + continue + if x + 1 < w: + px[i + 1] += err * 7 >> 4 + if has_next: + j = i + w + if x > 0: + px[j - 1] += err * 3 >> 4 + px[j] += err * 5 >> 4 + if x + 1 < w: + px[j + 1] += err >> 4 + return out + + +def atkinson(gray, w, h): + px = list(gray) + out = bytearray(w * h) + n = w * h + for y in range(h): + base = y * w + for x in range(w): + i = base + x + old = px[i] + if old < 128: + out[i] = 1 + err = old + else: + err = old - 255 + err >>= 3 + if not err: + continue + if x + 1 < w: + px[i + 1] += err + if x + 2 < w: + px[i + 2] += err + for j in (i + w - 1 if x > 0 else -1, i + w, i + w + 1 if x + 1 < w else -1, i + 2 * w): + if 0 <= j < n: + px[j] += err + return out + + +def embolden(bits, w, h): + '''Thickens strokes by one dot to the right, so thin text prints darker.''' + out = bytearray(len(bits)) + for y in range(h): + row = int.from_bytes(bits[y * w:(y + 1) * w], "big") + out[y * w:(y + 1) * w] = (row | (row >> 8)).to_bytes(w, "big") + return out + + +def resolve_mode(mode, kind): + if mode != "auto": + return mode + return "photo" if kind == "image" else "sharp" + + +def process_page(page, opts): + '''Returns the rows of a page, as PRINT_WIDTH bytes of 0/1 each.''' + pb = page.load() + if page.kind != "text": + if opts.rotate: + pb = pb.rotate_simple(GdkPixbuf.PixbufRotation.CLOCKWISE) + if opts.crop: + pb = crop_to_content(pb) + + w = PRINT_WIDTH + h = max(1, round(pb.get_height() * w / pb.get_width())) + gray = to_gray(flatten_scale(pb, w, h)) + if opts.brightness or opts.contrast: + gray = gray.translate(tone_table(opts.brightness, opts.contrast)) + + mode = resolve_mode(opts.mode, page.kind) + if mode == "photo": + bits = floyd_steinberg(gray, w, h) + elif mode == "atkinson": + bits = atkinson(gray, w, h) + else: + bits = gray.translate(_THRESHOLD_TABLE) + if opts.bold: + bits = embolden(bits, w, h) + + rows = [bytes(bits[y * w:(y + 1) * w]) for y in range(h)] + if opts.crop and page.kind != "text": + rows = trim_blank_rows(rows) + return rows + + +def trim_blank_rows(rows): + marked = [i for i, r in enumerate(rows) if any(r)] + if not marked: + return rows + start = max(0, marked[0] - TRIM_PADDING_ROWS) + end = min(len(rows), marked[-1] + 1 + TRIM_PADDING_ROWS) + return rows[start:end] + + +def preview_bytes(rows): + '''Grayscale (G8) preview image: black = 0, white = 255.''' + return b"".join(rows).translate(_PREVIEW_TABLE) + + +def parse_page_range(text, count): + '''"1-3, 5" -> indices [0, 1, 2, 4]. Empty means all pages. Raises + ValueError if the range is not valid.''' + text = text.strip() + if not text: + return list(range(count)) + pages = [] + for part in text.replace(";", ",").split(","): + part = part.strip() + if not part: + continue + if "-" in part: + a, b = part.split("-", 1) + start = int(a) if a.strip() else 1 + end = int(b) if b.strip() else count + else: + start = end = int(part) + if not (1 <= start <= end <= count): + raise ValueError(part) + pages.extend(range(start - 1, end)) + return pages diff --git a/catprinter_gui/render.py b/catprinter_gui/render.py new file mode 100644 index 0000000..5060851 --- /dev/null +++ b/catprinter_gui/render.py @@ -0,0 +1,196 @@ +"""Turns files of many types into pages (GdkPixbuf) ready to be processed.""" +import io +import os +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from typing import Callable + +import cairo +import gi + +gi.require_version("GdkPixbuf", "2.0") +gi.require_version("Pango", "1.0") +gi.require_version("PangoCairo", "1.0") +from gi.repository import GdkPixbuf, Gio, Pango, PangoCairo # noqa: E402 + +from catprinter.cmds import PRINT_WIDTH # noqa: E402 + +# Blank rows of dots between pages or files. +PAGE_GAP = 24 +TEXT_MARGIN = 4 +TEXT_LINES_PER_CHUNK = 120 + +OFFICE_TYPES = ( + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.graphics", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/rtf", + "text/rtf", + "text/html", +) + +# Types offered in "Open With" and in the context menu of Files. +MIME_TYPES = ( + "image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp", + "image/tiff", "image/svg+xml", "image/x-icon", "image/heif", "image/avif", + "application/pdf", "text/plain", "text/markdown", "text/csv", +) + OFFICE_TYPES + + +class RenderError(Exception): + pass + + +@dataclass +class Page: + load: Callable[[], GdkPixbuf.Pixbuf] + # "image", "document" or "text": decides the automatic mode and whether + # the page can be cropped or rotated. + kind: str + source: str + # Blank rows left after this page. + gap: int = PAGE_GAP + + +def content_type(path): + info = Gio.File.new_for_path(path).query_info( + "standard::content-type", Gio.FileQueryInfoFlags.NONE, None) + return info.get_content_type() + + +def is_a(ct, types): + return any(Gio.content_type_is_a(ct, t) for t in types) + + +def render_file(path, workdir, text_size=22): + '''Returns the pages of a file. workdir is a temporary directory for + intermediate files.''' + if not os.path.isfile(path): + raise RenderError(f"No existe el archivo {path}") + name = os.path.basename(path) + ct = content_type(path) + + if is_a(ct, ("application/pdf",)): + return render_pdf(path, workdir, name) + if ct.startswith("image/"): + return [Page(lambda: load_image(path), "image", name)] + if is_a(ct, OFFICE_TYPES): + return render_office(path, workdir, name) + if is_a(ct, ("text/plain",)): + mono = not Gio.content_type_equals(ct, "text/plain") + return render_text(read_text(path), text_size, mono, name) + # Last resort: LibreOffice opens lots of formats. + if shutil.which("soffice"): + return render_office(path, workdir, name) + raise RenderError(f"No sé imprimir archivos de tipo {Gio.content_type_get_description(ct)}") + + +def load_image(path): + try: + if Gio.content_type_is_a(content_type(path), "image/svg+xml"): + pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, PRINT_WIDTH * 3, -1, True) + else: + pb = GdkPixbuf.Pixbuf.new_from_file(path) + except Exception as e: + raise RenderError(f"No se pudo abrir la imagen {os.path.basename(path)}: {e}") + return pb.apply_embedded_orientation() or pb + + +def render_pdf(path, workdir, name): + if not shutil.which("pdftoppm"): + raise RenderError("Para imprimir PDF instala poppler-utils: sudo apt install poppler-utils") + outdir = tempfile.mkdtemp(dir=workdir) + # 1400 px on the long side is enough to crop margins or rotate the page. + result = subprocess.run( + ["pdftoppm", "-png", "-scale-to", "1400", path, os.path.join(outdir, "p")], + capture_output=True, text=True) + files = sorted( + (f for f in os.listdir(outdir) if f.endswith(".png")), + key=lambda f: int(f.rsplit("-", 1)[1].split(".")[0])) + if result.returncode != 0 or not files: + raise RenderError(f"No se pudo leer el PDF {name}: {result.stderr.strip()}") + return [ + Page(lambda f=os.path.join(outdir, f): GdkPixbuf.Pixbuf.new_from_file(f), "document", name) + for f in files + ] + + +def render_office(path, workdir, name): + soffice = shutil.which("soffice") or shutil.which("libreoffice") + if not soffice: + raise RenderError("Para imprimir documentos de oficina instala LibreOffice.") + outdir = tempfile.mkdtemp(dir=workdir) + # Use a separate profile so it works while LibreOffice is open. + profile = "file://" + os.path.join(workdir, "lo-profile") + try: + subprocess.run( + [soffice, f"-env:UserInstallation={profile}", "--headless", + "--convert-to", "pdf", "--outdir", outdir, path], + capture_output=True, timeout=180) + except subprocess.TimeoutExpired: + raise RenderError(f"LibreOffice tardó demasiado en convertir {name}") + pdfs = [f for f in os.listdir(outdir) if f.endswith(".pdf")] + if not pdfs: + raise RenderError(f"LibreOffice no pudo convertir {name}") + return render_pdf(os.path.join(outdir, pdfs[0]), workdir, name) + + +def read_text(path): + with open(path, "rb") as f: + raw = f.read() + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("latin-1") + + +def surface_to_pixbuf(surface): + buf = io.BytesIO() + surface.write_to_png(buf) + loader = GdkPixbuf.PixbufLoader.new_with_type("png") + loader.write(buf.getvalue()) + loader.close() + return loader.get_pixbuf() + + +def render_text(text, size_px, mono, name): + lines = text.expandtabs(4).splitlines() or [""] + font = Pango.FontDescription.from_string("Monospace" if mono else "Sans") + font.set_absolute_size(size_px * Pango.SCALE) + + def render_chunk(chunk): + def load(): + ctx = cairo.Context(cairo.ImageSurface(cairo.FORMAT_RGB24, 1, 1)) + layout = PangoCairo.create_layout(ctx) + layout.set_width((PRINT_WIDTH - 2 * TEXT_MARGIN) * Pango.SCALE) + layout.set_wrap(Pango.WrapMode.WORD_CHAR) + layout.set_font_description(font) + layout.set_text(chunk, -1) + height = max(1, layout.get_pixel_extents()[1].height) + + surface = cairo.ImageSurface(cairo.FORMAT_RGB24, PRINT_WIDTH, height) + ctx = cairo.Context(surface) + ctx.set_source_rgb(1, 1, 1) + ctx.paint() + ctx.set_source_rgb(0, 0, 0) + ctx.move_to(TEXT_MARGIN, 0) + PangoCairo.update_layout(ctx, layout) + PangoCairo.show_layout(ctx, layout) + return surface_to_pixbuf(surface) + return load + + # Split in chunks to avoid huge images; they are printed without gaps. + chunks = ["\n".join(lines[i:i + TEXT_LINES_PER_CHUNK]) + for i in range(0, len(lines), TEXT_LINES_PER_CHUNK)] + pages = [Page(render_chunk(c), "text", name, gap=0) for c in chunks] + pages[-1].gap = PAGE_GAP + return pages diff --git a/catprinter_gui/settings.py b/catprinter_gui/settings.py new file mode 100644 index 0000000..e2aee6d --- /dev/null +++ b/catprinter_gui/settings.py @@ -0,0 +1,64 @@ +"""User settings, stored in ~/.config/catprinter/gui.json.""" +import json +import os + +PATH = os.path.join( + os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), + "catprinter", "gui.json") + +# Bumped when stored values must be reset because their meaning changed. +VERSION = 3 + +# Values the official iPrint app uses for the SC03h: speed 10 and a base +# energy of 5000 for images and 8000 for text. Each concentration level above +# or below the default adds or removes 15 % of energy. +SPEED_RANGE = (5, 40) +LEVEL_RANGE = (1, 9) +DEFAULT_LEVEL = 4 +IMAGE_ENERGY = 5000 +TEXT_ENERGY = 8000 + +DEFAULTS = { + "mode": "auto", + "brightness": 0, + "contrast": 0, + "rotate": False, + "crop": True, + "bold": True, + "text_size": 22, + "level": DEFAULT_LEVEL, + # Higher values print slower and darker. + "speed": 10, +} + + +def load(): + settings = dict(DEFAULTS) + try: + with open(PATH) as f: + saved = json.load(f) + except (OSError, ValueError): + return settings + if saved.get("version", 1) < VERSION: + saved.pop("speed", None) + settings.update({k: v for k, v in saved.items() if k in DEFAULTS}) + if not SPEED_RANGE[0] <= settings["speed"] <= SPEED_RANGE[1]: + settings["speed"] = DEFAULTS["speed"] + if not LEVEL_RANGE[0] <= settings["level"] <= LEVEL_RANGE[1]: + settings["level"] = DEFAULT_LEVEL + return settings + + +def save(settings): + try: + os.makedirs(os.path.dirname(PATH), exist_ok=True) + with open(PATH, "w") as f: + json.dump(dict(settings, version=VERSION), f, indent=2) + except OSError: + pass + + +def energy_value(level, text_mode): + '''Thermal energy for a concentration level, as the official app computes it.''' + base = TEXT_ENERGY if text_mode else IMAGE_ENERGY + return round(base * (1 + (level - DEFAULT_LEVEL) * 0.15)) diff --git a/catprinter_gui/window.py b/catprinter_gui/window.py new file mode 100644 index 0000000..e0852e6 --- /dev/null +++ b/catprinter_gui/window.py @@ -0,0 +1,686 @@ +"""Main window: file preview, print settings and printer connection.""" +import os +import shutil +import tempfile +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 + +from catprinter.cmds import PRINT_WIDTH # noqa: E402 +from catprinter_gui import APP_ID, APP_NAME, process, render, settings # noqa: E402 +from catprinter_gui.printer import PrinterService, friendly_error # noqa: E402 + +DOTS_PER_MM = 8 # 203 dpi +RECONNECT_INTERVAL_S = 15 +KEEP_ALIVE_INTERVAL_S = 60 +RECONNECT_DELAY_S = 2 +LOW_BATTERY = 20 +REPROCESS_DELAY_MS = 250 +RELOAD_DELAY_MS = 400 + +MODE_LABELS = ("Automático", "Foto (tramado)", "Texto y dibujos (nítido)", "Suave (Atkinson)") + +CSS = """ +.preview-area { background-color: alpha(@window_fg_color, 0.06); } +.paper { + background-color: white; + box-shadow: 0 1px 3px alpha(black, 0.25), 0 4px 12px alpha(black, 0.12); + border-radius: 2px; + padding: 8px 0; +} +.status-dot { min-width: 10px; min-height: 10px; border-radius: 5px; } +.status-dot.connected { background-color: @success_color; } +.status-dot.disconnected { background-color: @warning_color; } +.status-dot.busy { background-color: @accent_color; } +.battery { font-feature-settings: "tnum"; } +.battery.low { color: @warning_color; } +""" + + +class Window(Adw.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app, title=APP_NAME, default_width=1000, + default_height=740, icon_name=APP_ID) + self.settings = settings.load() + self.printer = PrinterService(self.on_disconnected) + self.workdir = tempfile.mkdtemp(prefix="catprinter-") + + # Printer state. + self.device = None + self.scanning = False + self.connecting = False + self.connected = False + self.querying = False + self.printing = False + self.status = None + self.error = None + + # Document state. The generation counters discard results of + # background work that was superseded by newer work. + self.files = [] + self.pages = [] + self.rows = [] + self.text_mode = False + self.load_generation = 0 + self.process_generation = 0 + self.process_timeout = 0 + self.reload_timeout = 0 + + provider = Gtk.CssProvider() + provider.load_from_string(CSS) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + self.toasts = Adw.ToastOverlay() + toolbar = Adw.ToolbarView() + toolbar.add_top_bar(self.build_header()) + self.banner = Adw.Banner() + self.banner.connect("button-clicked", lambda *_: self.refresh()) + toolbar.add_top_bar(self.banner) + + self.stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE) + self.stack.add_named(self.build_empty(), "empty") + self.stack.add_named(self.build_loading(), "loading") + self.stack.add_named(self.build_main(), "main") + toolbar.set_content(self.stack) + self.toasts.set_child(toolbar) + self.set_content(self.toasts) + + drop = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY) + drop.connect("drop", self.on_drop) + self.add_controller(drop) + self.connect("close-request", self.on_close) + + self.scan() + GLib.timeout_add_seconds(RECONNECT_INTERVAL_S, self.auto_reconnect) + GLib.timeout_add_seconds(KEEP_ALIVE_INTERVAL_S, self.keep_alive) + + # ------------------------------------------------------------ Widgets + + def build_header(self): + header = Adw.HeaderBar() + title_box = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER) + self.status_dot = Gtk.Box(valign=Gtk.Align.CENTER, css_classes=["status-dot"]) + self.title = Adw.WindowTitle(title=APP_NAME) + title_box.append(self.status_dot) + title_box.append(self.title) + header.set_title_widget(title_box) + + open_button = Gtk.Button(icon_name="document-open-symbolic", + tooltip_text="Abrir archivos") + open_button.connect("clicked", lambda *_: self.choose_files()) + header.pack_start(open_button) + + header.pack_end(Gtk.MenuButton(icon_name="open-menu-symbolic", menu_model=build_menu(), + primary=True, tooltip_text="Menú principal")) + self.refresh_button = Gtk.Button(icon_name="view-refresh-symbolic", + tooltip_text="Reconectar o actualizar la batería") + self.refresh_button.connect("clicked", lambda *_: self.refresh()) + header.pack_end(self.refresh_button) + + self.battery_box = Gtk.Box(spacing=4, css_classes=["battery"], visible=False) + self.battery_icon = Gtk.Image() + self.battery_label = Gtk.Label() + self.battery_box.append(self.battery_icon) + self.battery_box.append(self.battery_label) + header.pack_end(self.battery_box) + return header + + def build_empty(self): + button = Gtk.Button(label="Abrir archivo…", halign=Gtk.Align.CENTER, + css_classes=["pill", "suggested-action"]) + button.connect("clicked", lambda *_: self.choose_files()) + return Adw.StatusPage( + icon_name=APP_ID, + title="Imprime lo que quieras", + description="Arrastra aquí imágenes, PDF, documentos o archivos de texto", + child=button) + + def build_loading(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12, + valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) + box.append(Adw.Spinner(width_request=48, height_request=48)) + self.loading_label = Gtk.Label(css_classes=["dim-label"]) + box.append(self.loading_label) + return box + + def build_main(self): + content = Gtk.Box() + + # A strip of paper showing exactly what will be printed. + self.paper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, css_classes=["paper"], + halign=Gtk.Align.CENTER, valign=Gtk.Align.START, + margin_top=24, margin_bottom=24, margin_start=24, margin_end=24) + scroller = Gtk.ScrolledWindow(hexpand=True, vexpand=True, child=self.paper, + css_classes=["preview-area"], + hscrollbar_policy=Gtk.PolicyType.NEVER, + min_content_width=PRINT_WIDTH + 48) + overlay = Gtk.Overlay(child=scroller) + self.preview_spinner = Adw.Spinner(width_request=32, height_request=32, + halign=Gtk.Align.END, valign=Gtk.Align.START, + margin_top=12, margin_end=12, visible=False) + overlay.add_overlay(self.preview_spinner) + content.append(overlay) + content.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + + sidebar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, width_request=400, + hexpand=False) + page = Adw.PreferencesPage(vexpand=True) + page.add(self.build_file_group()) + page.add(self.build_image_group()) + page.add(self.build_print_group()) + sidebar.append(page) + sidebar.append(self.build_print_bar()) + content.append(sidebar) + return content + + def build_file_group(self): + group = Adw.PreferencesGroup(title="Archivo") + self.file_row = Adw.ActionRow() + add_button = Gtk.Button(icon_name="list-add-symbolic", valign=Gtk.Align.CENTER, + tooltip_text="Añadir más archivos", css_classes=["flat"]) + add_button.connect("clicked", lambda *_: self.choose_files(append=True)) + clear_button = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER, + tooltip_text="Quitar todo", css_classes=["flat"]) + clear_button.connect("clicked", lambda *_: self.clear()) + self.file_row.add_suffix(add_button) + self.file_row.add_suffix(clear_button) + group.add(self.file_row) + + self.pages_row = Adw.EntryRow(title="Páginas (ej. 1-3, 5)") + self.pages_row.connect("changed", lambda *_: self.schedule_process()) + group.add(self.pages_row) + return group + + def build_image_group(self): + s = self.settings + group = Adw.PreferencesGroup(title="Imagen") + + self.mode_row = Adw.ComboRow(title="Modo", subtitle="Fotos con tramado, documentos nítidos", + model=Gtk.StringList.new(MODE_LABELS), + selected=process.MODES.index(s["mode"])) + self.mode_row.connect("notify::selected", self.on_image_setting_changed) + group.add(self.mode_row) + + self.brightness = self.scale_row(group, "Brillo", -100, 100, s["brightness"]) + self.contrast = self.scale_row(group, "Contraste", -100, 100, s["contrast"]) + + self.bold_row = Adw.SwitchRow(title="Engrosar trazos", active=s["bold"], + subtitle="El texto fino sale más oscuro y legible") + self.crop_row = Adw.SwitchRow(title="Recortar márgenes blancos", active=s["crop"], + subtitle="Aprovecha todo el ancho del papel") + self.rotate_row = Adw.SwitchRow(title="Girar 90°", active=s["rotate"], + subtitle="Útil para imágenes y páginas horizontales") + for row in (self.bold_row, self.crop_row, self.rotate_row): + row.connect("notify::active", self.on_image_setting_changed) + group.add(row) + + self.text_size_row = Adw.SpinRow.new_with_range(12, 60, 1) + self.text_size_row.set_title("Tamaño de letra") + self.text_size_row.set_subtitle("Para archivos de texto") + self.text_size_row.set_value(s["text_size"]) + self.text_size_row.connect("notify::value", self.on_text_size_changed) + group.add(self.text_size_row) + return group + + def build_print_group(self): + s = self.settings + group = Adw.PreferencesGroup( + title="Impresión", + description="Si sale clarito, sube la concentración de a un nivel. Con poca " + "batería puede imprimir en blanco.") + self.level = self.scale_row(group, "Concentración", *settings.LEVEL_RANGE, s["level"], + affects_preview=False, + subtitle="4 = normal (como la app oficial)") + self.level.add_mark(settings.DEFAULT_LEVEL, Gtk.PositionType.BOTTOM, None) + self.speed = self.scale_row(group, "Lentitud", *settings.SPEED_RANGE, s["speed"], + affects_preview=False, subtitle="Más lenta = más oscura") + return group + + def scale_row(self, group, title, low, high, value, affects_preview=True, subtitle=None): + row = Adw.ActionRow(title=title, subtitle=subtitle) + scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, low, high, 1) + scale.set_value(value) + scale.set_hexpand(True) + scale.set_size_request(170, -1) + scale.set_value_pos(Gtk.PositionType.LEFT) + scale.set_draw_value(True) + if low < 0: + scale.add_mark(0, Gtk.PositionType.BOTTOM, None) + scale.connect("value-changed", self.on_image_setting_changed if affects_preview + else self.save_settings) + row.add_suffix(scale) + group.add(row) + return scale + + def build_print_bar(self): + bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, + margin_top=12, margin_bottom=18, margin_start=18, margin_end=18) + self.length_label = Gtk.Label(css_classes=["dim-label", "caption"]) + self.progress = Gtk.ProgressBar(visible=False) + self.print_button = Gtk.Button( + css_classes=["pill", "suggested-action"], + child=Adw.ButtonContent(icon_name="printer-symbolic", label="Imprimir")) + self.print_button.connect("clicked", lambda *_: self.print()) + bar.append(self.length_label) + bar.append(self.progress) + bar.append(self.print_button) + return bar + + # ------------------------------------------------------------ Printer + # + # The window finds the printer, connects and stays connected like the + # phone app does. If the connection drops, it looks for it again. + + @property + def busy(self): + return self.scanning or self.connecting or self.printing or self.querying + + def scan(self): + if self.busy: + return + self.scanning = True + self.update_status() + self.printer.scan(self.on_scan_done) + + def on_scan_done(self, device, error): + self.scanning = False + self.device = device + self.error = friendly_error(error) if error else None + self.update_status() + if device: + self.connect_printer() + + def connect_printer(self): + if self.busy or not self.device: + return + self.connecting = True + self.update_status() + self.printer.connect(self.device, self.on_connected) + + def on_connected(self, status, error): + self.connecting = False + if error: + # auto_reconnect() will try again. + self.device = None + self.error = "No se pudo conectar: " + friendly_error(error) + self.update_status() + return + self.connected = True + self.error = None + self.set_status(status) + + def on_disconnected(self): + was_connected = self.connected + self.connected = False + self.device = None + self.status = None + self.error = "Se perdió la conexión con la impresora. Reconectando…" + self.update_status() + if was_connected and not self.printing: + GLib.timeout_add_seconds(RECONNECT_DELAY_S, lambda: self.scan() or GLib.SOURCE_REMOVE) + + def auto_reconnect(self): + if not self.connected and not self.busy: + if self.device: + self.connect_printer() + else: + self.scan() + return GLib.SOURCE_CONTINUE + + def keep_alive(self): + # Polling the state keeps the link active and the battery level fresh. + if self.connected and not self.busy: + self.query_status(quiet=True) + return GLib.SOURCE_CONTINUE + + def refresh(self): + if self.connected: + self.query_status() + elif self.device: + self.connect_printer() + else: + self.scan() + + def query_status(self, quiet=False): + if self.busy or not self.connected: + return + self.querying = True + if not quiet: + self.update_status() + self.printer.query_status(lambda status, error: self.on_status(status, error, quiet)) + + def on_status(self, status, error, quiet=False): + self.querying = False + if error: + if not quiet: + self.toasts.add_toast(Adw.Toast( + title="No se pudo leer el estado: " + friendly_error(error), timeout=5)) + self.update_status() + return + self.set_status(status) + + def set_status(self, status): + if status.battery is None and self.status: + status.battery = self.status.battery + self.status = status + self.update_status() + + def update_status(self): + name = self.device.name if self.device else "" + if self.printing: + state, subtitle = "busy", f"Imprimiendo en {name}…" + elif self.connecting: + state, subtitle = "busy", f"Conectando con {name}…" + elif self.connected: + state, subtitle = "connected", f"{name} conectada" + elif self.scanning: + state, subtitle = "busy", "Buscando impresora…" + else: + state, subtitle = "disconnected", "Impresora no conectada" + self.status_dot.set_css_classes(["status-dot", state]) + self.title.set_subtitle(subtitle) + + problems = self.status_problems() + if not self.connected and not self.busy: + self.banner.set_title( + self.error or "No se encontró la impresora. Enciéndela y acércala al equipo.") + self.banner.set_button_label("Buscar de nuevo") + self.banner.set_revealed(True) + elif self.connected and problems: + self.banner.set_title(" · ".join(problems)) + self.banner.set_button_label("Comprobar") + self.banner.set_revealed(True) + else: + self.banner.set_revealed(False) + self.update_battery() + self.refresh_button.set_sensitive(not self.busy) + self.update_print_button() + + def status_problems(self): + s = self.status + if not s: + return [] + problems = [] + if s.no_paper: + problems.append("Sin papel") + if s.cover_open: + problems.append("Tapa abierta") + if s.overheat: + problems.append("Cabezal caliente: espera unos minutos") + if self.battery_low(): + problems.append("Batería baja: carga la impresora, puede imprimir en blanco") + return problems + + def battery_low(self): + s = self.status + return bool(s) and not s.charging and ( + s.low_battery or (s.battery is not None and s.battery <= LOW_BATTERY)) + + def update_battery(self): + battery = self.status.battery if self.status and self.connected else None + self.battery_box.set_visible(battery is not None) + if battery is None: + return + level = min(100, round(battery / 10) * 10) + if self.status.charging: + suffix = "-charged" if level == 100 else "-charging" + tooltip = f"Batería de la impresora: {battery} % (cargando)" + else: + suffix, tooltip = "", f"Batería de la impresora: {battery} %" + self.battery_icon.set_from_icon_name(f"battery-level-{level}{suffix}-symbolic") + self.battery_label.set_label(f"{battery} %") + self.battery_box.set_tooltip_text(tooltip) + if self.battery_low(): + self.battery_box.add_css_class("low") + else: + self.battery_box.remove_css_class("low") + + def update_print_button(self): + self.print_button.set_sensitive(bool(self.connected and self.rows and not self.busy)) + self.print_button.set_tooltip_text( + None if self.connected else "Esperando a que se conecte la impresora") + + def print(self): + if not (self.connected and self.rows) or self.busy: + return + self.printing = True + self.save_settings() + self.progress.set_fraction(0) + self.progress.set_visible(True) + self.update_status() + self.printer.print_rows( + list(self.rows), + settings.energy_value(int(self.level.get_value()), self.text_mode), + int(self.speed.get_value()), self.text_mode, + self.progress.set_fraction, self.on_print_done) + + def on_print_done(self, status, error): + self.printing = False + self.progress.set_visible(False) + if error: + self.show_error("No se pudo imprimir", friendly_error(error)) + if self.printer.is_connected: + self.update_status() + else: + self.on_disconnected() + self.scan() + else: + self.toasts.add_toast(Adw.Toast(title="¡Impreso!", timeout=3)) + self.set_status(status) + + # -------------------------------------------------------------- Files + + def choose_files(self, append=False): + dialog = Gtk.FileDialog(title="Elegir archivos para imprimir") + + def done(dialog, result): + try: + files = dialog.open_multiple_finish(result) + except GLib.Error: + return # Cancelled. + paths = [f.get_path() for f in files if f.get_path()] + if paths: + self.load_files(paths, append) + + dialog.open_multiple(self, None, done) + + def on_drop(self, _target, value, _x, _y): + paths = [f.get_path() for f in value.get_files() if f.get_path()] + if paths: + self.load_files(paths, append=bool(self.files)) + return True + + def load_files(self, paths, append=False): + files = (self.files if append else []) + list(paths) + self.load_generation += 1 + generation = self.load_generation + self.loading_label.set_label( + "Abriendo " + ", ".join(os.path.basename(p) for p in paths) + "…") + self.stack.set_visible_child_name("loading") + text_size = int(self.text_size_row.get_value()) + + def work(): + loaded, pages, errors = [], [], [] + for path in files: + try: + pages += render.render_file(path, self.workdir, text_size) + loaded.append(path) + except Exception as e: + errors.append(str(e)) + GLib.idle_add(self.on_files_loaded, generation, loaded, pages, errors) + + threading.Thread(target=work, daemon=True).start() + + def on_files_loaded(self, generation, files, pages, errors): + if generation != self.load_generation: + return + for error in errors: + self.toasts.add_toast(Adw.Toast(title=error, timeout=6)) + if not pages: + self.clear() + return + self.files = files + self.pages = pages + names = [os.path.basename(f) for f in files] + if len(names) == 1: + title = names[0] + subtitle = f"{len(pages)} página" + ("s" if len(pages) != 1 else "") + else: + title = f"{len(names)} archivos" + subtitle = ", ".join(names[:3]) + ("…" if len(names) > 3 else "") + self.file_row.set_title(GLib.markup_escape_text(title)) + self.file_row.set_subtitle(GLib.markup_escape_text(subtitle)) + self.pages_row.set_visible(len(pages) > 1) + self.text_size_row.set_visible(any(p.kind == "text" for p in pages)) + self.stack.set_visible_child_name("main") + self.process_now() + + def clear(self): + self.load_generation += 1 + self.process_generation += 1 + self.files, self.pages, self.rows = [], [], [] + self.pages_row.set_text("") + self.stack.set_visible_child_name("empty") + self.update_print_button() + + # --------------------------------------------------------- Processing + + def options(self): + return process.Options( + mode=process.MODES[self.mode_row.get_selected()], + brightness=int(self.brightness.get_value()), + contrast=int(self.contrast.get_value()), + rotate=self.rotate_row.get_active(), + crop=self.crop_row.get_active(), + bold=self.bold_row.get_active()) + + def on_image_setting_changed(self, *_): + self.save_settings() + self.schedule_process() + + def on_text_size_changed(self, *_): + # Text is rendered when loading, so text files have to be reloaded. + self.save_settings() + if not any(p.kind == "text" for p in self.pages): + return + if self.reload_timeout: + GLib.source_remove(self.reload_timeout) + + def reload(): + self.reload_timeout = 0 + self.load_files(self.files) + return GLib.SOURCE_REMOVE + + self.reload_timeout = GLib.timeout_add(RELOAD_DELAY_MS, reload) + + def schedule_process(self): + if not self.pages: + return + if self.process_timeout: + GLib.source_remove(self.process_timeout) + self.process_timeout = GLib.timeout_add(REPROCESS_DELAY_MS, self.process_now) + + def process_now(self): + self.process_timeout = 0 + try: + selected = process.parse_page_range(self.pages_row.get_text(), len(self.pages)) + self.pages_row.remove_css_class("error") + except ValueError: + self.pages_row.add_css_class("error") + return GLib.SOURCE_REMOVE + + self.process_generation += 1 + generation = self.process_generation + pages = [self.pages[i] for i in selected] + opts = self.options() + # Like the official app: text mode and energy only if everything is sharp. + text_mode = bool(pages) and all( + process.resolve_mode(opts.mode, p.kind) == "sharp" for p in pages) + self.preview_spinner.set_visible(True) + + def work(): + chunks = [] + try: + for page in pages: + if generation != self.process_generation: + return + rows = process.process_page(page, opts) + chunks.append((rows, [bytes(PRINT_WIDTH)] * page.gap)) + except Exception as e: + GLib.idle_add(self.on_processed, generation, None, text_mode, str(e)) + return + GLib.idle_add(self.on_processed, generation, chunks, text_mode, None) + + threading.Thread(target=work, daemon=True).start() + return GLib.SOURCE_REMOVE + + def on_processed(self, generation, chunks, text_mode, error): + if generation != self.process_generation: + return + self.preview_spinner.set_visible(False) + if error: + self.toasts.add_toast(Adw.Toast(title=f"Error al procesar: {error}", timeout=6)) + return + # The printer feeds paper at the end, so drop the last page's gap. + if chunks: + chunks[-1] = (chunks[-1][0], []) + + while child := self.paper.get_first_child(): + self.paper.remove(child) + self.rows = [] + self.text_mode = text_mode + for rows, gap in chunks: + self.rows += rows + gap + # One picture per page: GPUs can't show textures that are too tall. + texture = Gdk.MemoryTexture.new( + PRINT_WIDTH, len(rows), Gdk.MemoryFormat.G8, + GLib.Bytes.new(process.preview_bytes(rows)), PRINT_WIDTH) + self.paper.append(Gtk.Picture(paintable=texture, can_shrink=False, + margin_bottom=len(gap))) + + cm = len(self.rows) / DOTS_PER_MM / 10 + self.length_label.set_label(f"Largo aproximado: {cm:.1f} cm de papel") + self.update_print_button() + + # -------------------------------------------------------------- Misc + + def save_settings(self, *_): + s = self.settings + s["mode"] = process.MODES[self.mode_row.get_selected()] + s["brightness"] = int(self.brightness.get_value()) + s["contrast"] = int(self.contrast.get_value()) + s["rotate"] = self.rotate_row.get_active() + s["crop"] = self.crop_row.get_active() + s["bold"] = self.bold_row.get_active() + s["text_size"] = int(self.text_size_row.get_value()) + s["level"] = int(self.level.get_value()) + s["speed"] = int(self.speed.get_value()) + settings.save(s) + + def show_error(self, heading, body): + dialog = Adw.AlertDialog(heading=heading, body=body) + dialog.add_response("ok", "Aceptar") + dialog.present(self) + + def on_close(self, *_): + self.save_settings() + self.printer.close() + shutil.rmtree(self.workdir, ignore_errors=True) + return False + + +def build_menu(): + menu = Gio.Menu() + integration = Gio.Menu() + integration.append("Integrar con Archivos y el menú de apps", "app.install") + integration.append("Quitar integración", "app.uninstall") + menu.append_section(None, integration) + about = Gio.Menu() + about.append(f"Acerca de {APP_NAME}", "app.about") + menu.append_section(None, about) + return menu diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/css/app.css b/docs/css/app.css new file mode 100644 index 0000000..afce14d --- /dev/null +++ b/docs/css/app.css @@ -0,0 +1,380 @@ +:root { + color-scheme: light dark; + --bg: #fafafb; + --fg: #1e1e24; + --dim: #6e6e78; + --card: #ffffff; + --border: rgb(0 0 0 / 0.1); + --preview-bg: #ebebed; + --accent: #e66100; + --accent-hover: #cc5600; + --on-accent: #ffffff; + --success: #26a269; + --warning: #c88800; + --error: #c01c28; + --shadow: 0 1px 3px rgb(0 0 0 / 0.25), 0 4px 12px rgb(0 0 0 / 0.12); + --radius: 12px; + font-family: system-ui, -apple-system, "Segoe UI", Cantarell, Roboto, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #222226; + --fg: #f0f0f2; + --dim: #a0a0aa; + --card: #2e2e33; + --border: rgb(255 255 255 / 0.1); + --preview-bg: #1a1a1d; + --accent: #ff7800; + --accent-hover: #ff8f2e; + --on-accent: #1a1a1d; + --success: #57e389; + --warning: #f8e45c; + --error: #ff7b63; + } +} + +* { box-sizing: border-box; } + +[hidden] { display: none !important; } + +html, body { height: 100%; } + +body { + margin: 0; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--fg); + font-size: 15px; + line-height: 1.4; +} + +h1, h2, h3, p { margin: 0; } + +a { color: var(--accent); } + +/* Top bar */ + +.topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + padding-top: max(10px, env(safe-area-inset-top)); + background: var(--card); + border-bottom: 1px solid var(--border); +} + +.topbar__title { flex: 1; min-width: 0; } + +.topbar__title h1 { font-size: 16px; font-weight: 700; } + +.topbar__status { + display: flex; + align-items: center; + gap: 6px; + color: var(--dim); + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dot { + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--warning); +} + +.dot.connected { background: var(--success); } +.dot.busy { background: var(--accent); animation: pulse 1s infinite alternate; } + +@keyframes pulse { to { opacity: 0.3; } } + +.battery { + display: inline-flex; + align-items: center; + gap: 4px; + font-variant-numeric: tabular-nums; + font-size: 14px; +} + +.battery svg { width: 22px; height: 22px; } +.battery.low { color: var(--warning); } + +/* Buttons */ + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 36px; + padding: 0 16px; + border: none; + border-radius: 8px; + background: var(--border); + color: var(--fg); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.button:hover { filter: brightness(0.95); } +.button:disabled { opacity: 0.45; cursor: default; filter: none; } +.button--accent { background: var(--accent); color: var(--on-accent); } +.button--accent:hover { background: var(--accent-hover); filter: none; } +.button--pill { border-radius: 999px; min-height: 44px; padding: 0 28px; } +.button--wide { width: 100%; } + +.icon-button { + display: inline-grid; + place-items: center; + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--fg); + cursor: pointer; +} + +.icon-button:hover { background: var(--border); } +.icon-button svg { width: 16px; height: 16px; fill: currentColor; } + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Banner */ + +.banner { + padding: 10px 16px; + background: color-mix(in srgb, var(--warning) 22%, var(--card)); + text-align: center; + font-weight: 600; +} + +/* Layout */ + +.app { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) 400px; + min-height: 0; +} + +.preview { + position: relative; + overflow: auto; + background: var(--preview-bg); + padding: 24px 16px; +} + +.preview.dragging { outline: 3px dashed var(--accent); outline-offset: -12px; } + +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 100%; + text-align: center; + color: var(--dim); +} + +.empty h2 { color: var(--fg); font-size: 24px; } + +.paper { + width: 384px; + max-width: 100%; + margin: 0 auto; + padding: 8px 0; + background: #fff; + border-radius: 2px; + box-shadow: var(--shadow); +} + +.paper canvas { display: block; width: 100%; image-rendering: pixelated; } + +.spinner { + position: absolute; + top: 16px; + right: 16px; + width: 28px; + height: 28px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Settings */ + +.settings { + display: flex; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--border); +} + +.settings__scroll { flex: 1; overflow: auto; padding: 8px 16px 16px; } + +.group { margin-top: 16px; } +.group h2 { font-size: 14px; font-weight: 700; margin: 0 0 8px 4px; } +.group__hint { color: var(--dim); font-size: 13px; margin: -4px 4px 8px; } + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.row { + display: flex; + align-items: center; + gap: 10px; + min-height: 52px; + padding: 8px 12px; +} + +.row + .row { border-top: 1px solid var(--border); } + +.row__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow-wrap: anywhere; +} + +.row__text small { color: var(--dim); font-size: 12.5px; } + +.row output { + min-width: 2.5em; + text-align: right; + color: var(--dim); + font-variant-numeric: tabular-nums; +} + +.row input[type="range"] { width: 150px; accent-color: var(--accent); } + +.row select, +.row input[type="text"], +.row input[type="number"] { + max-width: 50%; + min-height: 34px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg); + font: inherit; +} + +.row input[type="number"] { width: 5em; } +.row input.invalid { border-color: var(--error); color: var(--error); } + +.switch { + appearance: none; + flex: none; + position: relative; + width: 46px; + height: 26px; + margin: 0; + border-radius: 999px; + background: var(--border); + cursor: pointer; + transition: background 0.15s; +} + +.switch::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.3); + transition: transform 0.15s; +} + +.switch:checked { background: var(--accent); } +.switch:checked::after { transform: translateX(20px); } + +.print-bar { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px; + padding-bottom: max(16px, env(safe-area-inset-bottom)); + border-top: 1px solid var(--border); + background: var(--bg); +} + +.print-bar__length { text-align: center; color: var(--dim); font-size: 13px; } +.print-bar progress { width: 100%; accent-color: var(--accent); } + +/* Unsupported browsers */ + +.unsupported { + flex: 1; + max-width: 640px; + margin: 0 auto; + padding: 40px 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + text-align: center; +} + +.unsupported h2 { font-size: 22px; } +.unsupported h3 { margin-top: 12px; font-size: 16px; } +.unsupported ul { text-align: left; padding-left: 20px; margin: 0; display: grid; gap: 10px; } +.unsupported code { font-size: 13px; overflow-wrap: anywhere; } + +/* Toasts */ + +.toasts { + position: fixed; + left: 50%; + bottom: 96px; + transform: translateX(-50%); + display: grid; + gap: 8px; + width: min(480px, calc(100% - 32px)); + pointer-events: none; +} + +.toast { + padding: 10px 16px; + border-radius: 10px; + background: #303036; + color: #fff; + box-shadow: var(--shadow); + text-align: center; + animation: toast-in 0.2s ease-out; +} + +@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + +/* Phones: preview on top, settings below */ + +@media (max-width: 800px) { + .app { display: flex; flex-direction: column; overflow: auto; } + .app > * { flex-shrink: 0; } + .preview { overflow: visible; min-height: 50vh; padding: 16px; } + .settings { border-left: none; } + .settings__scroll { overflow: visible; } + .print-bar { position: sticky; bottom: 0; } + .topbar { flex-wrap: wrap; } + .row input[type="range"] { width: 120px; } +} diff --git a/docs/icons/icon-192.png b/docs/icons/icon-192.png new file mode 100644 index 0000000..6bf0da2 Binary files /dev/null and b/docs/icons/icon-192.png differ diff --git a/docs/icons/icon-512.png b/docs/icons/icon-512.png new file mode 100644 index 0000000..c06bca0 Binary files /dev/null and b/docs/icons/icon-512.png differ diff --git a/docs/icons/icon-maskable-512.png b/docs/icons/icon-maskable-512.png new file mode 100644 index 0000000..0bc455d Binary files /dev/null and b/docs/icons/icon-maskable-512.png differ diff --git a/docs/icons/icon.svg b/docs/icons/icon.svg new file mode 100644 index 0000000..3210c88 --- /dev/null +++ b/docs/icons/icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..cb15fb4 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,153 @@ + + + + + + Impresora térmica + + + + + + + + + +
+ +
+

Impresora térmica

+

Sin conectar

+
+ + + +
+ + + +
+
+
+ +

Imprime lo que quieras

+

Arrastra aquí imágenes, PDF o archivos de texto, pégalos o elígelos.

+ +
+ + +
+ + +
+ +
+ +

Este navegador no puede usar la impresora

+

Para conectarse a la impresora hace falta Web Bluetooth, y este navegador no lo tiene.

+

Alternativas

+
    +
  • Windows, macOS, Android y ChromeOS: abre esta página en Chrome, Edge u Opera.
  • +
  • Linux: usa la AppImage, o Chrome con chrome://flags/#enable-experimental-web-platform-features activado.
  • +
  • iPhone y iPad: ningún navegador de iOS tiene Web Bluetooth salvo Bluefy (gratis en la App Store); abre esta página en él.
  • +
  • Línea de comandos: print.py del repositorio funciona en Linux, macOS y Windows.
  • +
+
+ +
+ + diff --git a/docs/js/app.js b/docs/js/app.js new file mode 100644 index 0000000..1289999 --- /dev/null +++ b/docs/js/app.js @@ -0,0 +1,428 @@ +import { Printer } from "./printer.js"; +import { PRINT_WIDTH, printCommands } from "./protocol.js"; +import { + UnsupportedFileError, blankRows, expandText, loadFile, parsePageRange, previewCanvas, + processPage, resolveMode, +} from "./imaging.js"; + +const SETTINGS_KEY = "catprinter-settings"; +const DEFAULTS = { + mode: "auto", brightness: 0, contrast: 0, bold: true, crop: true, rotate: false, + textSize: 22, level: 4, speed: 10, +}; +// Values the official iPrint app uses for the SC03h: a base energy of 5000 +// for images and 8000 for text, and 15 % more or less per concentration level. +const IMAGE_ENERGY = 5000; +const TEXT_ENERGY = 8000; +const DEFAULT_LEVEL = 4; +const LOW_BATTERY = 20; +const DOTS_PER_MM = 8; +const KEEP_ALIVE_MS = 60000; +const REPROCESS_DELAY_MS = 250; + +const $ = (id) => document.getElementById(id); + +const state = { + settings: loadSettings(), + sources: [], // Pages as loaded; text is laid out when processing. + pages: [], // Pages ready to process. + rows: [], + textMode: false, + processing: 0, // Generation counter: discards superseded results. + printing: false, +}; + +const printer = new Printer(); + +if (!Printer.supported || !window.isSecureContext) { + showUnsupported(); +} else { + start(); +} + +function showUnsupported() { + const ua = navigator.userAgent; + let reason = "Para conectarse a la impresora hace falta Web Bluetooth, y este navegador no lo tiene."; + if (!window.isSecureContext) { + reason = "Web Bluetooth solo funciona en páginas seguras (https)."; + } else if (/iPhone|iPad|iPod/.test(ua)) { + reason = "Safari y los demás navegadores de iPhone y iPad no tienen Web Bluetooth."; + } else if (/Firefox\//.test(ua)) { + reason = "Firefox no tiene Web Bluetooth."; + } else if (/Linux/.test(ua) && /Chrome\//.test(ua)) { + reason = "En Linux, Chrome trae Web Bluetooth desactivado."; + } + $("unsupported-reason").textContent = reason; + $("unsupported").hidden = false; + $("connect").hidden = true; + $("status-text").textContent = "No compatible"; +} + +function start() { + $("app").hidden = false; + bindSettings(); + bindFiles(); + bindPrinter(); + bindInstall(); + receiveLaunchedFiles(); + if ("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js"); +} + +// ----------------------------------------------------------------- Settings + +function loadSettings() { + try { + return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY)) }; + } catch { + return { ...DEFAULTS }; + } +} + +function saveSettings() { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(state.settings)); + } catch { + // Storage may be unavailable (private mode); settings just won't persist. + } +} + +function bindSettings() { + for (const input of document.querySelectorAll("[data-setting]")) { + const key = input.dataset.setting; + if (input.type === "checkbox") input.checked = state.settings[key]; + else input.value = state.settings[key]; + showValue(key); + + input.addEventListener("input", () => { + state.settings[key] = input.type === "checkbox" ? input.checked + : input.type === "range" || input.type === "number" ? Number(input.value) + : input.value; + showValue(key); + saveSettings(); + // Concentration and speed only affect the printer, not the preview. + if (key !== "level" && key !== "speed") scheduleProcess(); + }); + } + $("pages").addEventListener("input", () => scheduleProcess()); +} + +function showValue(key) { + const output = document.querySelector(`output[data-for="${key}"]`); + if (output) output.textContent = state.settings[key]; +} + +function energy() { + const base = state.textMode ? TEXT_ENERGY : IMAGE_ENERGY; + return Math.round(base * (1 + (state.settings.level - DEFAULT_LEVEL) * 0.15)); +} + +// -------------------------------------------------------------------- Files + +function bindFiles() { + $("file-input").addEventListener("change", (e) => openFiles([...e.target.files])); + $("add-input").addEventListener("change", (e) => openFiles([...e.target.files], true)); + $("clear").addEventListener("click", clear); + + const preview = $("preview"); + window.addEventListener("dragover", (e) => { + e.preventDefault(); + preview.classList.add("dragging"); + }); + window.addEventListener("dragleave", (e) => { + if (!e.relatedTarget) preview.classList.remove("dragging"); + }); + window.addEventListener("drop", (e) => { + e.preventDefault(); + preview.classList.remove("dragging"); + const files = [...e.dataTransfer.files]; + if (files.length) openFiles(files, state.sources.length > 0); + }); + window.addEventListener("paste", (e) => { + const files = [...e.clipboardData.files]; + if (files.length) openFiles(files, state.sources.length > 0); + }); +} + +async function openFiles(files, append = false) { + showSpinner(true); + const sources = append ? [...state.sources] : []; + for (const file of files) { + try { + sources.push(...await loadFile(file)); + } catch (error) { + toast(error instanceof UnsupportedFileError ? error.message + : `No se pudo abrir «${file.name}»: ${error.message}`); + } + } + showSpinner(false); + if (!sources.length) { + clear(); + return; + } + state.sources = sources; + await process(); +} + +function clear() { + state.processing++; + state.sources = []; + state.pages = []; + state.rows = []; + $("pages").value = ""; + $("file-input").value = ""; + $("add-input").value = ""; + $("empty").hidden = false; + $("paper").hidden = true; + $("file-group").hidden = true; + $("text-size-row").hidden = true; + $("length").textContent = ""; + updatePrintButton(); +} + +function describeFiles() { + const names = [...new Set(state.sources.map((p) => p.name))]; + const count = state.pages.length; + if (names.length === 1) { + $("file-title").textContent = names[0]; + $("file-subtitle").textContent = `${count} página${count === 1 ? "" : "s"}`; + } else { + $("file-title").textContent = `${names.length} archivos`; + $("file-subtitle").textContent = names.slice(0, 3).join(", ") + (names.length > 3 ? "…" : ""); + } + $("file-group").hidden = false; + $("pages-row").hidden = count < 2; + $("text-size-row").hidden = !state.sources.some((p) => p.kind === "text-source"); +} + +// --------------------------------------------------------------- Processing + +let processTimer = 0; + +function scheduleProcess() { + if (!state.sources.length) return; + clearTimeout(processTimer); + processTimer = setTimeout(process, REPROCESS_DELAY_MS); +} + +async function process() { + const generation = ++state.processing; + const s = state.settings; + state.pages = expandText(state.sources, s.textSize); + describeFiles(); + + let selected; + try { + selected = parsePageRange($("pages").value, state.pages.length); + $("pages").classList.remove("invalid"); + } catch { + $("pages").classList.add("invalid"); + return; + } + + const pages = selected.map((i) => state.pages[i]); + // Like the official app: text mode and energy only if everything is sharp. + const textMode = pages.length > 0 && pages.every((p) => resolveMode(s.mode, p.kind) === "sharp"); + showSpinner(true); + const chunks = []; + try { + for (const page of pages) { + const rows = await processPage(page, s); + if (generation !== state.processing) return; + chunks.push({ rows, gap: page.gap }); + } + } catch (error) { + toast(`Error al procesar: ${error.message}`); + return; + } finally { + if (generation === state.processing) showSpinner(false); + } + // The printer feeds paper at the end, so drop the last page's gap. + if (chunks.length) chunks[chunks.length - 1].gap = 0; + + const paper = $("paper"); + paper.replaceChildren(); + state.rows = []; + state.textMode = textMode; + for (const { rows, gap } of chunks) { + state.rows.push(...rows, ...blankRows(gap)); + // One canvas per page: browsers limit the height of a canvas. + const canvas = previewCanvas(rows); + canvas.style.marginBottom = `${(gap * 100) / PRINT_WIDTH}%`; + paper.append(canvas); + } + $("empty").hidden = true; + paper.hidden = false; + const cm = state.rows.length / DOTS_PER_MM / 10; + $("length").textContent = `Largo aproximado: ${cm.toFixed(1)} cm de papel`; + updatePrintButton(); +} + +function showSpinner(visible) { + $("spinner").hidden = !visible; +} + +// ------------------------------------------------------------------ Printer + +function bindPrinter() { + $("connect").addEventListener("click", async () => { + if (printer.connected) { + printer.disconnect(); + return; + } + setStatus("busy", "Conectando…"); + try { + await printer.choose(); + } catch (error) { + // NotFoundError means the user closed the chooser. + if (error.name !== "NotFoundError") toast(bluetoothError(error)); + updateStatus(); + } + }); + $("print").addEventListener("click", print); + + printer.addEventListener("connected", updateStatus); + printer.addEventListener("status", updateStatus); + printer.addEventListener("disconnected", () => { + updateStatus(); + if (printer.wantConnection) toast("Se perdió la conexión con la impresora. Reconectando…"); + }); + + setInterval(() => { + if (printer.connected && !state.printing) printer.queryStatus().catch(() => {}); + }, KEEP_ALIVE_MS); + + navigator.bluetooth.getAvailability?.().then((available) => { + if (!available) showBanner("El Bluetooth está apagado o este equipo no tiene Bluetooth."); + }); + updateStatus(); + // Reconnect to the printer chosen in a previous visit, when the browser allows it. + printer.restore().catch(() => updateStatus()); +} + +function setStatus(kind, text) { + $("status-dot").className = `dot ${kind}`; + $("status-text").textContent = text; +} + +function updateStatus() { + const status = printer.status; + if (state.printing) { + setStatus("busy", `Imprimiendo en ${printer.name}…`); + } else if (printer.connected) { + setStatus("connected", `${printer.name} conectada`); + } else if (printer.wantConnection) { + setStatus("busy", "Reconectando…"); + } else { + setStatus("", "Sin conectar"); + } + $("connect").textContent = printer.connected ? "Desconectar" : "Conectar"; + $("connect").classList.toggle("button--accent", !printer.connected); + + const battery = printer.connected ? status.battery : undefined; + $("battery").hidden = battery === undefined; + if (battery !== undefined) { + const low = batteryLow(status); + $("battery").classList.toggle("low", low); + $("battery-level").setAttribute("width", String(Math.max(1, (14 * battery) / 100))); + $("battery-text").textContent = `${battery} %${status.charging ? " ⚡" : ""}`; + $("battery").title = `Batería de la impresora: ${battery} %${status.charging ? " (cargando)" : ""}`; + } + + const problems = []; + if (printer.connected) { + if (status.noPaper) problems.push("Sin papel"); + if (status.coverOpen) problems.push("Tapa abierta"); + if (status.overheat) problems.push("Cabezal caliente: espera unos minutos"); + if (batteryLow(status)) problems.push("Batería baja: carga la impresora, puede imprimir en blanco"); + } + showBanner(problems.join(" · ")); + updatePrintButton(); +} + +function batteryLow(status) { + return !status.charging && (status.lowBattery + || (status.battery !== undefined && status.battery <= LOW_BATTERY)); +} + +function updatePrintButton() { + const button = $("print"); + button.disabled = !(printer.connected && state.rows.length && !state.printing); + button.title = printer.connected ? "" : "Conecta la impresora primero"; +} + +async function print() { + if (!printer.connected || !state.rows.length || state.printing) return; + state.printing = true; + const progress = $("progress"); + progress.value = 0; + progress.hidden = false; + updateStatus(); + try { + const data = printCommands(state.rows, + { energy: energy(), speed: state.settings.speed, textMode: state.textMode }); + await printer.print(data, (fraction) => { progress.value = fraction; }); + toast("¡Impreso!"); + } catch (error) { + toast(`No se pudo imprimir: ${bluetoothError(error)}`); + } finally { + state.printing = false; + progress.hidden = true; + updateStatus(); + } +} + +function bluetoothError(error) { + if (error.name === "SecurityError") return "El navegador no permitió usar el Bluetooth."; + if (error.name === "NetworkError") return "No se pudo conectar. ¿Está encendida y cerca?"; + return error.message || String(error); +} + +// --------------------------------------------------------------------- PWA + +function bindInstall() { + let installPrompt = null; + window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); + installPrompt = e; + $("install").hidden = false; + }); + $("install").addEventListener("click", async () => { + $("install").hidden = true; + await installPrompt?.prompt(); + installPrompt = null; + }); +} + +// Files opened with "Open With" (installed app) or shared from another app. +async function receiveLaunchedFiles() { + if ("launchQueue" in window) { + window.launchQueue.setConsumer(async ({ files }) => { + if (files?.length) openFiles(await Promise.all(files.map((f) => f.getFile()))); + }); + } + + // The service worker keeps shared files in memory (never on disk) until we ask. + const url = new URL(location.href); + if (!url.searchParams.has("compartido") || !("serviceWorker" in navigator)) return; + history.replaceState(null, "", url.pathname); + navigator.serviceWorker.addEventListener("message", ({ data }) => { + if (data?.type === "shared-files" && data.files.length) openFiles(data.files); + }); + const registration = await navigator.serviceWorker.ready; + registration.active.postMessage("shared-files"); +} + +// --------------------------------------------------------------------- Misc + +function showBanner(text) { + $("banner").textContent = text; + $("banner").hidden = !text; +} + +function toast(text) { + const element = document.createElement("div"); + element.className = "toast"; + element.textContent = text; + $("toasts").append(element); + setTimeout(() => element.remove(), 5000); +} diff --git a/docs/js/imaging.js b/docs/js/imaging.js new file mode 100644 index 0000000..743420e --- /dev/null +++ b/docs/js/imaging.js @@ -0,0 +1,366 @@ +// Turns files into rows of black and white dots: a port of catprinter_gui's +// render.py and process.py. Rows are Uint8Arrays of PRINT_WIDTH values, 1 = black. +import { PRINT_WIDTH } from "./protocol.js"; + +const PAGE_GAP = 24; +const TEXT_MARGIN = 4; +const TEXT_LINES_PER_CHUNK = 120; +const PDF_LONG_SIDE = 1400; +const CROP_THRESHOLD = 220; +const CROP_PADDING = 0.02; +const TRIM_PADDING_ROWS = 8; + +const PDFJS_URL = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"; +const PDFJS_WORKER_URL = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"; + +const TEXT_EXTENSIONS = /\.(txt|md|csv|log|json|xml|ya?ml|ini|conf|cfg|py|js|ts|c|h|cpp|java|sh|html?|css)$/i; +const PLAIN_TEXT = /\.(txt|md)$/i; + +export const MODES = ["auto", "photo", "sharp", "atkinson"]; + +export class UnsupportedFileError extends Error {} + +// Returns the pages of a file: [{ kind, name, gap, load: async () => canvas }]. +// kind is "image", "document" or "text". +export async function loadFile(file) { + const name = file.name; + if (file.type === "application/pdf" || /\.pdf$/i.test(name)) return loadPdf(file); + if (file.type.startsWith("image/")) { + const canvas = await imageToCanvas(file); + return [{ kind: "image", name, gap: PAGE_GAP, load: async () => canvas }]; + } + if (file.type.startsWith("text/") || TEXT_EXTENSIONS.test(name)) { + return [{ kind: "text-source", name, text: await file.text(), mono: !PLAIN_TEXT.test(name) }]; + } + throw new UnsupportedFileError( + `No se puede imprimir «${name}» desde el navegador. Conviértelo a PDF o imagen.`); +} + +// Text is laid out when processing, because it depends on the font size. +export function expandText(pages, textSize) { + return pages.flatMap((page) => (page.kind === "text-source" + ? renderText(page.text, textSize, page.mono, page.name) + : [page])); +} + +async function imageToCanvas(file) { + const url = URL.createObjectURL(file); + try { + const img = new Image(); + img.src = url; + await img.decode(); + // SVGs without a size decode as 0x0 in some browsers. + const width = img.naturalWidth || PRINT_WIDTH * 3; + const height = img.naturalHeight || PRINT_WIDTH * 3; + const canvas = createCanvas(width, height); + canvas.getContext("2d").drawImage(img, 0, 0, width, height); + return canvas; + } catch { + throw new UnsupportedFileError(`No se pudo abrir la imagen «${file.name}».`); + } finally { + URL.revokeObjectURL(url); + } +} + +let pdfjs = null; + +async function loadPdfJs() { + if (pdfjs) return pdfjs; + await new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = PDFJS_URL; + script.onload = resolve; + script.onerror = () => reject(new Error("No se pudo cargar el lector de PDF. ¿Hay conexión?")); + document.head.append(script); + }); + pdfjs = window.pdfjsLib; + pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; + return pdfjs; +} + +async function loadPdf(file) { + const lib = await loadPdfJs(); + const pdf = await lib.getDocument({ data: await file.arrayBuffer() }).promise; + const pages = []; + for (let n = 1; n <= pdf.numPages; n++) { + pages.push({ + kind: "document", + name: file.name, + gap: PAGE_GAP, + load: async () => { + const page = await pdf.getPage(n); + const base = page.getViewport({ scale: 1 }); + const viewport = page.getViewport( + { scale: PDF_LONG_SIDE / Math.max(base.width, base.height) }); + const canvas = createCanvas(Math.round(viewport.width), Math.round(viewport.height)); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + await page.render({ canvasContext: ctx, viewport }).promise; + return canvas; + }, + }); + } + return pages; +} + +function renderText(text, size, mono, name) { + const font = `${size}px ${mono ? "monospace" : "sans-serif"}`; + const measure = createCanvas(1, 1).getContext("2d"); + measure.font = font; + const maxWidth = PRINT_WIDTH - 2 * TEXT_MARGIN; + const lines = text.replace(/\t/g, " ").split(/\r?\n/).flatMap( + (line) => wrapLine(line, measure, maxWidth)); + const lineHeight = Math.ceil(size * 1.25); + + const pages = []; + for (let start = 0; start < lines.length; start += TEXT_LINES_PER_CHUNK) { + const chunk = lines.slice(start, start + TEXT_LINES_PER_CHUNK); + pages.push({ + kind: "text", + name, + gap: 0, + load: async () => { + const canvas = createCanvas(PRINT_WIDTH, chunk.length * lineHeight); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#000"; + ctx.font = font; + ctx.textBaseline = "top"; + chunk.forEach((line, i) => ctx.fillText(line, TEXT_MARGIN, i * lineHeight)); + return canvas; + }, + }); + } + if (pages.length) pages[pages.length - 1].gap = PAGE_GAP; + return pages; +} + +// Wraps at spaces when possible, otherwise anywhere (like Pango's WORD_CHAR). +function wrapLine(line, ctx, maxWidth) { + if (!line) return [""]; + const out = []; + let rest = line; + while (ctx.measureText(rest).width > maxWidth) { + let cut = rest.length; + while (cut > 1 && ctx.measureText(rest.slice(0, cut)).width > maxWidth) cut--; + const space = rest.lastIndexOf(" ", cut); + if (space > 0) cut = space + 1; + out.push(rest.slice(0, cut).trimEnd()); + rest = rest.slice(cut); + } + out.push(rest); + return out; +} + +function createCanvas(width, height) { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, width); + canvas.height = Math.max(1, height); + return canvas; +} + +function rotate(canvas) { + const out = createCanvas(canvas.height, canvas.width); + const ctx = out.getContext("2d"); + ctx.translate(out.width, 0); + ctx.rotate(Math.PI / 2); + ctx.drawImage(canvas, 0, 0); + return out; +} + +// Draws canvas (or a part of it) at width x height over a white background. +function flatten(canvas, width, height, sx = 0, sy = 0, sw = canvas.width, sh = canvas.height) { + const out = createCanvas(width, height); + const ctx = out.getContext("2d", { willReadFrequently: true }); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, width, height); + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(canvas, sx, sy, sw, sh, 0, 0, width, height); + return out; +} + +function toGray(canvas) { + const { data } = canvas.getContext("2d", { willReadFrequently: true }) + .getImageData(0, 0, canvas.width, canvas.height); + const gray = new Uint8Array(canvas.width * canvas.height); + for (let i = 0, j = 0; i < gray.length; i++, j += 4) { + gray[i] = (77 * data[j] + 150 * data[j + 1] + 29 * data[j + 2]) >> 8; + } + return gray; +} + +// Crops white margins so the content uses the whole paper width. +// Returns the source rectangle [x, y, width, height]. +function contentBox(canvas) { + const sw = Math.min(canvas.width, 600); + const sh = Math.max(1, Math.round(canvas.height * sw / canvas.width)); + const gray = toGray(flatten(canvas, sw, sh)); + let top = -1, bottom = -1, left = sw, right = -1; + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + if (gray[y * sw + x] < CROP_THRESHOLD) { + if (top < 0) top = y; + bottom = y; + if (x < left) left = x; + if (x > right) right = x; + } + } + } + if (top < 0) return [0, 0, canvas.width, canvas.height]; + const sx = canvas.width / sw, sy = canvas.height / sh; + const pad = Math.round(Math.max(canvas.width, canvas.height) * CROP_PADDING); + const x0 = Math.max(0, Math.floor(left * sx) - pad); + const y0 = Math.max(0, Math.floor(top * sy) - pad); + const x1 = Math.min(canvas.width, Math.floor((right + 1) * sx) + pad); + const y1 = Math.min(canvas.height, Math.floor((bottom + 1) * sy) + pad); + return [x0, y0, x1 - x0, y1 - y0]; +} + +function toneTable(brightness, contrast) { + const c = contrast * 2.55; + const factor = (259 * (c + 255)) / (255 * (259 - c)); + return Uint8Array.from({ length: 256 }, (_, v) => + Math.max(0, Math.min(255, Math.round(factor * (v - 128) + 128 + brightness * 2.55)))); +} + +function threshold(gray) { + return gray.map((v) => (v < 128 ? 1 : 0)); +} + +function floydSteinberg(gray, w, h) { + const px = Int16Array.from(gray); + const out = new Uint8Array(w * h); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = y * w + x; + const old = px[i]; + const err = old < 128 ? old : old - 255; + if (old < 128) out[i] = 1; + if (!err) continue; + if (x + 1 < w) px[i + 1] += (err * 7) >> 4; + if (y + 1 < h) { + if (x > 0) px[i + w - 1] += (err * 3) >> 4; + px[i + w] += (err * 5) >> 4; + if (x + 1 < w) px[i + w + 1] += err >> 4; + } + } + } + return out; +} + +function atkinson(gray, w, h) { + const px = Int16Array.from(gray); + const out = new Uint8Array(w * h); + const n = w * h; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = y * w + x; + const old = px[i]; + if (old < 128) out[i] = 1; + const err = (old < 128 ? old : old - 255) >> 3; + if (!err) continue; + if (x + 1 < w) px[i + 1] += err; + if (x + 2 < w) px[i + 2] += err; + for (const j of [x > 0 ? i + w - 1 : -1, i + w, x + 1 < w ? i + w + 1 : -1, i + 2 * w]) { + if (j >= 0 && j < n) px[j] += err; + } + } + } + return out; +} + +// Thickens strokes by one dot to the right, so thin text prints darker. +function embolden(bits, w, h) { + const out = bits.slice(); + for (let y = 0; y < h; y++) { + for (let x = 1; x < w; x++) { + if (bits[y * w + x - 1]) out[y * w + x] = 1; + } + } + return out; +} + +export function resolveMode(mode, kind) { + if (mode !== "auto") return mode; + return kind === "image" ? "photo" : "sharp"; +} + +// options: { mode, brightness, contrast, rotate, crop, bold } +export async function processPage(page, options) { + let canvas = await page.load(); + const isText = page.kind === "text"; + if (!isText && options.rotate) canvas = rotate(canvas); + const box = !isText && options.crop ? contentBox(canvas) : [0, 0, canvas.width, canvas.height]; + + const w = PRINT_WIDTH; + const h = Math.max(1, Math.round(box[3] * w / box[2])); + let gray = toGray(flatten(canvas, w, h, ...box)); + if (options.brightness || options.contrast) { + const table = toneTable(options.brightness, options.contrast); + gray = gray.map((v) => table[v]); + } + + const mode = resolveMode(options.mode, page.kind); + let bits = mode === "photo" ? floydSteinberg(gray, w, h) + : mode === "atkinson" ? atkinson(gray, w, h) + : threshold(gray); + if (options.bold) bits = embolden(bits, w, h); + + let rows = Array.from({ length: h }, (_, y) => bits.subarray(y * w, (y + 1) * w)); + if (options.crop && !isText) rows = trimBlankRows(rows); + return rows; +} + +function trimBlankRows(rows) { + const marked = rows.map((row, i) => (row.some((v) => v) ? i : -1)).filter((i) => i >= 0); + if (!marked.length) return rows; + return rows.slice(Math.max(0, marked[0] - TRIM_PADDING_ROWS), + Math.min(rows.length, marked[marked.length - 1] + 1 + TRIM_PADDING_ROWS)); +} + +export function blankRows(count) { + return Array.from({ length: count }, () => new Uint8Array(PRINT_WIDTH)); +} + +// Draws rows on a canvas, black on white, exactly as they will be printed. +export function previewCanvas(rows) { + const canvas = createCanvas(PRINT_WIDTH, rows.length); + const ctx = canvas.getContext("2d"); + const image = ctx.createImageData(PRINT_WIDTH, Math.max(1, rows.length)); + rows.forEach((row, y) => { + for (let x = 0; x < PRINT_WIDTH; x++) { + const i = (y * PRINT_WIDTH + x) * 4; + const v = row[x] ? 0 : 255; + image.data[i] = image.data[i + 1] = image.data[i + 2] = v; + image.data[i + 3] = 255; + } + }); + ctx.putImageData(image, 0, 0); + return canvas; +} + +// "1-3, 5" -> [0, 1, 2, 4]. Empty means all pages. Throws if not valid. +export function parsePageRange(text, count) { + text = text.trim(); + if (!text) return Array.from({ length: count }, (_, i) => i); + const pages = []; + for (let part of text.replace(/;/g, ",").split(",")) { + part = part.trim(); + if (!part) continue; + let start, end; + if (part.includes("-")) { + const [a, b] = part.split("-", 2).map((s) => s.trim()); + start = a ? Number(a) : 1; + end = b ? Number(b) : count; + } else { + start = end = Number(part); + } + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || start > end || end > count) { + throw new RangeError(part); + } + for (let i = start - 1; i < end; i++) pages.push(i); + } + return pages; +} diff --git a/docs/js/printer.js b/docs/js/printer.js new file mode 100644 index 0000000..e6f488e --- /dev/null +++ b/docs/js/printer.js @@ -0,0 +1,211 @@ +// Web Bluetooth connection to the printer: a port of PrinterConnection in +// catprinter/ble.py. Emits "status", "connected" and "disconnected" events. +import { + BUSY_NOTIFICATION, CMD_DEV_STATE, GET_BATTERY, GET_DEV_STATE, READY_NOTIFICATION, + parseNotification, +} from "./protocol.js"; + +const SERVICES = [0xae30, 0xaf30, 0xae00]; +const TX_CHARACTERISTIC = 0xae01; +const RX_CHARACTERISTIC = 0xae02; +const NAME_PREFIXES = ["GB0", "GT01", "MX", "YT01", "PD01", "SC03", "_ZZ00"]; + +// Largest packet the official app sends; some browsers only allow 20 bytes. +const PACKET_SIZE = 177; +const SMALL_PACKET_SIZE = 20; +const WAIT_AFTER_PACKET_MS = 20; +const STATUS_WAIT_MS = 1500; +const DONE_TIMEOUT_MS = 30000; +const RECONNECT_DELAYS_MS = [1000, 3000, 10000]; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const sameBytes = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]); + +// A promise that can be resolved from outside, like asyncio.Event. +class Signal { + constructor() { this.reset(); this.set(); } + reset() { this.done = false; this.promise = new Promise((r) => { this.resolve = r; }); } + set() { this.done = true; this.resolve(); } + wait(timeoutMs) { + if (this.done) return Promise.resolve(true); + return Promise.race([this.promise.then(() => true), sleep(timeoutMs).then(() => false)]); + } +} + +export class Printer extends EventTarget { + static get supported() { + return "bluetooth" in navigator; + } + + constructor() { + super(); + this.device = null; + this.tx = null; + this.status = {}; + this.packetSize = PACKET_SIZE; + this.canSend = new Signal(); + this.stateReceived = new Signal(); + this.queue = Promise.resolve(); + this.wantConnection = false; + } + + get connected() { + return Boolean(this.device?.gatt.connected && this.tx); + } + + get name() { + return this.device?.name || "Impresora"; + } + + // Shows the browser's device chooser. Must be called from a user gesture. + async choose() { + const device = await navigator.bluetooth.requestDevice({ + filters: [ + ...SERVICES.map((service) => ({ services: [service] })), + ...NAME_PREFIXES.map((namePrefix) => ({ namePrefix })), + ], + optionalServices: SERVICES, + }); + await this.useDevice(device); + } + + // Reconnects to a printer chosen in a previous visit, if the browser allows it. + async restore() { + if (!navigator.bluetooth.getDevices) return false; + const devices = await navigator.bluetooth.getDevices(); + const device = devices.find((d) => NAME_PREFIXES.some((p) => d.name?.startsWith(p))); + if (!device) return false; + await this.useDevice(device); + return true; + } + + async useDevice(device) { + if (this.device && this.device !== device) this.disconnect(); + this.device = device; + this.wantConnection = true; + device.addEventListener("gattserverdisconnected", this.onDisconnected); + try { + await this.open(); + } catch (error) { + this.wantConnection = false; + throw error; + } + } + + async open() { + const server = await this.device.gatt.connect(); + const service = await findService(server); + this.tx = await service.getCharacteristic(TX_CHARACTERISTIC); + const rx = await service.getCharacteristic(RX_CHARACTERISTIC); + rx.addEventListener("characteristicvaluechanged", this.onNotification); + await rx.startNotifications(); + this.dispatchEvent(new Event("connected")); + await this.queryStatus(); + } + + disconnect() { + this.wantConnection = false; + if (this.device?.gatt.connected) this.device.gatt.disconnect(); + this.tx = null; + } + + onDisconnected = async () => { + this.tx = null; + this.dispatchEvent(new Event("disconnected")); + for (const delay of RECONNECT_DELAYS_MS) { + if (!this.wantConnection) return; + await sleep(delay); + try { + await this.open(); + return; + } catch { + // Not in range yet; try again later. + } + } + }; + + onNotification = (event) => { + const data = new Uint8Array(event.target.value.buffer); + parseNotification(data, this.status); + if (sameBytes(data, READY_NOTIFICATION)) { + this.canSend.set(); + } else if (sameBytes(data, BUSY_NOTIFICATION)) { + this.canSend.reset(); + } else if (data[2] === CMD_DEV_STATE) { + this.stateReceived.set(); + this.dispatchEvent(new CustomEvent("status", { detail: { ...this.status } })); + } + }; + + // Runs printer operations one after another. + exclusive(task) { + const run = this.queue.then(task, task); + this.queue = run.catch(() => {}); + return run; + } + + queryStatus() { + return this.exclusive(async () => { + this.stateReceived.reset(); + await this.write(GET_DEV_STATE); + await this.write(GET_BATTERY); + await this.stateReceived.wait(STATUS_WAIT_MS); + return { ...this.status }; + }); + } + + // Sends data from printCommands() and waits until the printer has processed + // it. onProgress is called with the fraction of data sent so far. + print(data, onProgress) { + return this.exclusive(async () => { + this.canSend.set(); + let offset = 0; + while (offset < data.length) { + const size = Math.min(this.packetSize, data.length - offset); + if (offset + size >= data.length) { + // The data ends with a state query; its answer means "done". + this.stateReceived.reset(); + } + if (!(await this.canSend.wait(DONE_TIMEOUT_MS))) { + throw new Error("La impresora no respondió a tiempo."); + } + try { + await this.write(data.subarray(offset, offset + size)); + } catch (error) { + if (this.packetSize === SMALL_PACKET_SIZE || offset > 0) throw error; + // This browser can't send large packets: retry with small ones. + this.packetSize = SMALL_PACKET_SIZE; + continue; + } + offset += size; + onProgress?.(offset / data.length); + await sleep(WAIT_AFTER_PACKET_MS); + } + await this.stateReceived.wait(DONE_TIMEOUT_MS); + return { ...this.status }; + }); + } + + async write(bytes) { + if (!this.connected) throw new Error("La impresora no está conectada."); + const value = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes); + if (this.tx.writeValueWithoutResponse) { + await this.tx.writeValueWithoutResponse(value); + } else { + await this.tx.writeValue(value); + } + } +} + +async function findService(server) { + for (const uuid of SERVICES) { + try { + const service = await server.getPrimaryService(uuid); + await service.getCharacteristic(TX_CHARACTERISTIC); + return service; + } catch { + // Not this one. + } + } + throw new Error("Este dispositivo no parece una impresora compatible."); +} diff --git a/docs/js/protocol.js b/docs/js/protocol.js new file mode 100644 index 0000000..3661d71 --- /dev/null +++ b/docs/js/protocol.js @@ -0,0 +1,157 @@ +// Cat printer protocol: a port of catprinter/cmds.py and catprinter/status.py. +// Every command is 51 78 00 ff. + +export const PRINT_WIDTH = 384; + +// Flow control notifications: the printer asks us to pause when its buffer is full. +export const READY_NOTIFICATION = [0x51, 0x78, 0xae, 0x01, 0x01, 0x00, 0x00, 0x00, 0xff]; +export const BUSY_NOTIFICATION = [0x51, 0x78, 0xae, 0x01, 0x01, 0x00, 0x10, 0x70, 0xff]; + +export const CMD_DEV_STATE = 0xa3; +const CMD_SET_QUALITY = 0xa4; +const CMD_LATTICE = 0xa6; +const CMD_UPDATE_DEVICE = 0xa9; +const CMD_FEED_PAPER = 0xa1; +const CMD_SET_ENERGY = 0xaf; +const CMD_DRAWING_MODE = 0xbe; +const CMD_SET_SPEED = 0xbd; +const CMD_BITMAP_ROW = 0xa2; +const CMD_RLE_ROW = 0xbf; +const CMD_BATTERY = 0xba; + +// The official app re-sends the speed every this many rows, and sets this +// speed before feeding the paper at the end. +const SPEED_RESEND_ROWS = 200; +const FEED_SPEED = 25; +const FEED_LINES = 48; + +const CRC_TABLE = (() => { + const table = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let bit = 0; bit < 8; bit++) { + c = c & 0x80 ? ((c << 1) ^ 0x07) & 0xff : (c << 1) & 0xff; + } + table[i] = c; + } + return table; +})(); + +export function crc8(bytes) { + let crc = 0; + for (const b of bytes) crc = CRC_TABLE[(crc ^ b) & 0xff]; + return crc; +} + +export function command(cmd, payload) { + return [0x51, 0x78, cmd, 0x00, payload.length & 0xff, payload.length >> 8, + ...payload, crc8(payload), 0xff]; +} + +export const GET_DEV_STATE = command(CMD_DEV_STATE, [0x00]); +export const GET_BATTERY = command(CMD_BATTERY, [0x00]); +const SET_QUALITY_200_DPI = command(CMD_SET_QUALITY, [0x32]); +const UPDATE_DEVICE = command(CMD_UPDATE_DEVICE, [0x00]); +const PRINT_IMAGE_MODE = command(CMD_DRAWING_MODE, [0x00]); +const PRINT_TEXT_MODE = command(CMD_DRAWING_MODE, [0x01]); +const FEED_PAPER = command(CMD_FEED_PAPER, [FEED_LINES, 0x00]); +const LATTICE_START = command(CMD_LATTICE, + [0xaa, 0x55, 0x17, 0x38, 0x44, 0x5f, 0x5f, 0x5f, 0x44, 0x38, 0x2c]); +const LATTICE_END = command(CMD_LATTICE, + [0xaa, 0x55, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x17]); + +// Energy is little-endian, as the official app sends it. +export const setEnergy = (value) => command(CMD_SET_ENERGY, [value & 0xff, (value >> 8) & 0xff]); + +// Higher values print slower and darker. +export const setSpeed = (value) => command(CMD_SET_SPEED, [value & 0xff]); + +function encodeRepetition(count, value, out) { + while (count > 0x7f) { + out.push(0x7f | (value << 7)); + count -= 0x7f; + } + if (count > 0) out.push((value << 7) | count); +} + +function runLengthEncode(row) { + const out = []; + let count = 0; + let last = -1; + for (const value of row) { + if (value === last) { + count++; + } else { + encodeRepetition(count, last, out); + count = 1; + } + last = value; + } + encodeRepetition(count, last, out); + return out; +} + +function bitmapEncode(row) { + const out = []; + for (let start = 0; start < row.length; start += 8) { + let byte = 0; + for (let bit = 0; bit < 8; bit++) { + if (row[start + bit]) byte |= 1 << bit; + } + out.push(byte); + } + return out; +} + +// row: PRINT_WIDTH values, 1 = black. Uses run-length encoding when it is shorter. +export function printRow(row) { + const rle = runLengthEncode(row); + if (rle.length > PRINT_WIDTH / 8) return command(CMD_BITMAP_ROW, bitmapEncode(row)); + return command(CMD_RLE_ROW, rle); +} + +// Builds the commands to print rows, the same sequence as the official app. +export function printCommands(rows, { energy, speed, textMode }) { + const parts = [ + GET_DEV_STATE, + SET_QUALITY_200_DPI, + setEnergy(energy), + textMode ? PRINT_TEXT_MODE : PRINT_IMAGE_MODE, + setSpeed(speed), + UPDATE_DEVICE, + LATTICE_START, + ]; + rows.forEach((row, i) => { + if (i && i % SPEED_RESEND_ROWS === 0) parts.push(setSpeed(speed)); + parts.push(printRow(row)); + }); + parts.push(setSpeed(FEED_SPEED), FEED_PAPER, FEED_PAPER, FEED_PAPER, LATTICE_END, GET_DEV_STATE); + + const data = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const part of parts) { + data.set(part, offset); + offset += part.length; + } + return data; +} + +// Updates status from a notification. The state answer of an SC03h looks like +// 51 78 a3 01 03 00 ff. +export function parseNotification(data, status) { + if (data.length < 8 || data[0] !== 0x51 || data[1] !== 0x78) return; + const length = data[4] | (data[5] << 8); + const payload = data.slice(6, 6 + length); + if (!payload.length) return; + if (data[2] === CMD_DEV_STATE) { + const flags = payload[0]; + status.noPaper = Boolean(flags & 0x01); + status.coverOpen = Boolean(flags & 0x02); + status.overheat = Boolean(flags & 0x04); + status.lowBattery = Boolean(flags & 0x08); + status.charging = Boolean(flags & 0x10); + if (payload.length >= 2 && payload[1] <= 100) status.battery = payload[1]; + } else if (data[2] === CMD_BATTERY && payload[0] <= 100) { + status.battery = payload[0]; + } +} diff --git a/docs/manifest.webmanifest b/docs/manifest.webmanifest new file mode 100644 index 0000000..fb98106 --- /dev/null +++ b/docs/manifest.webmanifest @@ -0,0 +1,44 @@ +{ + "id": "./", + "name": "Impresora térmica", + "short_name": "Impresora", + "description": "Imprime imágenes, PDF y texto en impresoras térmicas Bluetooth (GT01, GB0x, SC03h y similares).", + "lang": "es", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#fafafb", + "theme_color": "#e66100", + "categories": ["productivity", "utilities"], + "icons": [ + { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }, + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "share_target": { + "action": "./compartir", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "files": [ + { "name": "archivos", "accept": ["image/*", "application/pdf", "text/*"] } + ] + } + }, + "file_handlers": [ + { + "action": "./", + "accept": { + "image/png": [".png"], + "image/jpeg": [".jpg", ".jpeg"], + "image/gif": [".gif"], + "image/webp": [".webp"], + "image/bmp": [".bmp"], + "image/svg+xml": [".svg"], + "application/pdf": [".pdf"], + "text/plain": [".txt", ".md", ".csv", ".log"] + } + } + ] +} diff --git a/docs/sw.js b/docs/sw.js new file mode 100644 index 0000000..5404b9c --- /dev/null +++ b/docs/sw.js @@ -0,0 +1,92 @@ +// Service worker: makes the app work offline and receives shared files. +// Only the app's own files are cached; files to print are never stored. +// Bump VERSION whenever the files below change. +const VERSION = "v2"; +const SHELL_CACHE = `catprinter-shell-${VERSION}`; +const RUNTIME_CACHE = "catprinter-runtime"; +const LEGACY_CACHES = ["catprinter-shared"]; + +const SHELL_FILES = [ + "./", + "./index.html", + "./css/app.css", + "./js/app.js", + "./js/imaging.js", + "./js/printer.js", + "./js/protocol.js", + "./manifest.webmanifest", + "./icons/icon.svg", + "./icons/icon-192.png", + "./icons/icon-512.png", + "./icons/icon-maskable-512.png", +]; +const SHELL_PATHS = new Set(SHELL_FILES.map((file) => new URL(file, self.location).pathname)); + +// Files received from the share sheet, kept in memory until the page asks for them. +let sharedFiles = []; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_FILES))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil((async () => { + for (const key of await caches.keys()) { + const old = key.startsWith("catprinter-shell-") && key !== SHELL_CACHE; + if (old || LEGACY_CACHES.includes(key)) await caches.delete(key); + } + await self.clients.claim(); + })()); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + if (event.request.method === "POST" && url.pathname.endsWith("/compartir")) { + event.respondWith(receiveShare(event.request)); + } else if (event.request.method === "GET" && url.origin === location.origin + && SHELL_PATHS.has(url.pathname)) { + event.respondWith(staleWhileRevalidate(event.request)); + } else if (event.request.method === "GET" && url.hostname === "cdnjs.cloudflare.com") { + // Versioned library URLs (pdf.js) never change: cache them for offline use. + event.respondWith(cacheFirst(event.request)); + } +}); + +// Answers from the cache right away and refreshes it for the next visit. +async function staleWhileRevalidate(request) { + const cache = await caches.open(SHELL_CACHE); + const cached = await cache.match(request, { ignoreSearch: true }); + const network = fetch(request).then((response) => { + if (response.ok) cache.put(request, response.clone()); + return response; + }); + if (cached) { + network.catch(() => {}); + return cached; + } + return network; +} + +async function cacheFirst(request) { + const cache = await caches.open(RUNTIME_CACHE); + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; +} + +// Share target: keeps the shared files in memory and opens the page, which +// asks for them with a "shared-files" message. +async function receiveShare(request) { + const form = await request.formData(); + sharedFiles = form.getAll("archivos").filter((f) => f instanceof File); + return Response.redirect("./?compartido", 303); +} + +self.addEventListener("message", (event) => { + if (event.data !== "shared-files") return; + event.source.postMessage({ type: "shared-files", files: sharedFiles }); + sharedFiles = []; +}); diff --git a/print.py b/print.py index ba47cc6..c275158 100755 --- a/print.py +++ b/print.py @@ -38,6 +38,9 @@ def parse_args(): args.add_argument('-e', '--energy', type=lambda h: int(h.removeprefix("0x"), 16), help="Thermal energy. Between 0x0000 (light) and 0xffff (darker, default).", default="0xffff") + args.add_argument('-S', '--speed', type=int, default=None, + help="Print speed. Higher values print slower and darker. " + "The official app uses 10 for the SC03h.") return args.parse_args() @@ -72,7 +75,7 @@ def main(): return logger.info(f'✅ Read image: {bin_img.shape} (h, w) pixels') - data = cmds_print_img(bin_img, energy=args.energy) + data = cmds_print_img(bin_img, energy=args.energy, speed=args.speed) logger.info(f'✅ Generated BLE commands: {len(data)} bytes') # Try to autodiscover a printer if --device is not specified. diff --git a/requirements.txt b/requirements.txt index b281efe..f5562ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ bleak~=0.14.2 -numpy<2.0 -opencv-python<5.0 \ No newline at end of file +numpy>=1.26 +opencv-python<5.0