From 817b56076e5c3eb059b2e074cffd8035f91c8600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Cort=C3=A9s?= Date: Thu, 15 Oct 2026 05:39:54 +0200 Subject: [PATCH 1/8] fix(deps): allow numpy 2 so the project installs on Python 3.13 Bug: requirements pinned numpy<2.0, which has no wheels for Python 3.13 (Debian 13). pip had to build it from source and failed, so the CLI could not be installed. The code works with numpy 2, which opencv-python 4.x supports. Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From d2fd225d6ad69fe3a4533ef82c7e762deb0b4ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Cort=C3=A9s?= Date: Thu, 15 Oct 2026 05:39:54 +0200 Subject: [PATCH 2/8] fix(cmds): send print settings the way the official iPrint app does Compared against iPrint 1.3.87, which is what these printers are made for: - Energy was sent big-endian; iPrint sends it little-endian (5000 -> 88 13). Only symmetric values such as the 0xffff default worked. - The print speed (0xbd) was never set before printing, so the printer kept whatever speed it had. Add cmd_set_speed() and a speed argument (re-sent every 200 rows, as iPrint does), plus --speed in print.py. - cmd_feed_paper() did not feed paper: it sent 0xbd, the speed command. Replace it with cmd_set_speed(FEED_SPEED), which produces the same bytes. - Send "update device" (0xa9) so the printer applies energy and speed. - Select image mode (0xbe 00) or text mode (0xbe 01) instead of always using text mode through the misnamed cmd_apply_energy(). - Add the battery query command (0xba). Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- catprinter/cmds.py | 70 ++++++++++++++++++++++++++-------------------- print.py | 5 +++- 2 files changed, 43 insertions(+), 32 deletions(-) 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/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. From ac9b70f433dc8db87104a11407aa113cc89105aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Cort=C3=A9s?= Date: Thu, 15 Oct 2026 05:39:54 +0200 Subject: [PATCH 3/8] fix(ble): connect to dual-mode printers over LE and stop losing data Bugs found with an SC03h on Debian 13 (BlueZ 5.82): - The SC03h is dual-mode (classic SPP + BLE). BlueZ tried a classic connection and failed with "br-connection-profile-unavailable". Set the device's PreferredBearer to "le" before connecting (needs "Experimental = true" in /etc/bluetooth/main.conf) and retry a couple of times if BlueZ still picks the classic bearer. - BlueZ negotiated an MTU of 512 and the whole job was written in ~1 s using 509-byte packets, which can overflow the printer's buffer and give blank prints. Cap packets at 180 bytes like iPrint. - The flow control notifications (pause/resume) were ignored. Pause sending while the printer reports that its buffer is full. - The SC03h never sends the "ready" notification, so every print ended with a 30 s timeout. Use the answer to the state query that ends every job as the "done" signal instead. New features: - PrinterConnection keeps a connection open between prints and reports disconnections, so apps can stay connected like the phone app does. - Parse the printer state (no paper, cover open, overheating, low battery, charging) and the battery level into PrinterStatus. Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- catprinter/ble.py | 229 ++++++++++++++++++++++++++++++++++++------- catprinter/status.py | 43 ++++++++ 2 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 catprinter/status.py diff --git a/catprinter/ble.py b/catprinter/ble.py index 2a5893f..91241bd 100644 --- a/catprinter/ble.py +++ b/catprinter/ble.py @@ -12,6 +12,8 @@ except ImportError: BleakClientBlueZDBus = None from catprinter import logger +from catprinter.cmds import CMD_GET_BATTERY, CMD_GET_DEV_STATE +from catprinter.status import CMD_DEV_STATE, PrinterStatus, parse_notification # For some reason, bleak reports the 0xaf30 service on my macOS, while it reports # 0xae30 (which I believe is correct) on my Raspberry Pi. This hacky workaround @@ -25,13 +27,24 @@ TX_CHARACTERISTIC_UUID = "0000ae01-0000-1000-8000-00805f9b34fb" RX_CHARACTERISTIC_UUID = "0000ae02-0000-1000-8000-00805f9b34fb" +# Flow control: the printer asks us to pause when its buffer is full. PRINTER_READY_NOTIFICATION = b"\x51\x78\xae\x01\x01\x00\x00\x00\xff" +PRINTER_BUSY_NOTIFICATION = b"\x51\x78\xae\x01\x01\x00\x10\x70\xff" SCAN_TIMEOUT_S = 10 +CONNECT_ATTEMPTS = 3 + +# Largest packet the official app sends (its "imgMTU" for these printers). +# Larger writes can overflow the printer's buffer and result in blank prints. +MAX_PACKET_SIZE = 180 + # Wait time after sending each chunk of data through BLE. WAIT_AFTER_EACH_CHUNK_S = 0.02 +# Time to wait for the answers to a status query. +STATUS_WAIT_S = 1.5 + # Wait for printer done event timeout. WAIT_FOR_PRINTER_DONE_TIMEOUT = 30 @@ -63,6 +76,15 @@ def filter_fn(device: BLEDevice, adv_data: AdvertisementData): return device +async def find_printer(timeout: int = SCAN_TIMEOUT_S) -> 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/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] From a73cd341d0897cc823761b8ac18a409ecc1810c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Cort=C3=A9s?= Date: Thu, 15 Oct 2026 05:42:40 +0200 Subject: [PATCH 4/8] feat(gui): add a GTK 4 app to print anything on cat printers A libadwaita app (Spanish UI) that: - Finds the printer, connects and stays connected like the phone app, reconnecting on its own if the link drops. - Shows the battery level and warns about low battery, no paper, open cover or overheating (the SC03h prints blank pages with low battery). - Prints images (SVG too), PDFs (pdftoppm), office documents (LibreOffice) and text files, with an exact preview of the dots that will be printed. - Offers photo dithering or sharp text, brightness, contrast, margin cropping, rotation, thicker strokes and a page range. - Uses the official app's concentration levels and print speed. - Can integrate itself with the desktop and the context menu of Files. Image processing is pure Python plus GdkPixbuf so the app runs on the system Python without numpy or OpenCV. Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- catprinter_gui/__init__.py | 7 + catprinter_gui/__main__.py | 30 + catprinter_gui/application.py | 68 ++ .../data/io.github.catprinter.Gui.svg | 29 + catprinter_gui/data/nautilus_extension.py | 36 + catprinter_gui/integration.py | 118 +++ catprinter_gui/printer.py | 139 ++++ catprinter_gui/process.py | 232 ++++++ catprinter_gui/render.py | 196 +++++ catprinter_gui/settings.py | 64 ++ catprinter_gui/window.py | 686 ++++++++++++++++++ 11 files changed, 1605 insertions(+) create mode 100644 catprinter_gui/__init__.py create mode 100644 catprinter_gui/__main__.py create mode 100644 catprinter_gui/application.py create mode 100644 catprinter_gui/data/io.github.catprinter.Gui.svg create mode 100644 catprinter_gui/data/nautilus_extension.py create mode 100644 catprinter_gui/integration.py create mode 100644 catprinter_gui/printer.py create mode 100644 catprinter_gui/process.py create mode 100644 catprinter_gui/render.py create mode 100644 catprinter_gui/settings.py create mode 100644 catprinter_gui/window.py 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 From 76213168704f10afacf699e68037969661504028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Cort=C3=A9s?= Date: Thu, 15 Oct 2026 05:43:17 +0200 Subject: [PATCH 5/8] build: package the GTK app as an AppImage appimage/build.sh produces dist/CatPrinter--x86_64.AppImage (about 1 MB). It bundles the app and its pure Python dependencies (bleak 0.14, dbus-next) and runs on the system's Python 3 and GTK 4, so it keeps working when the distribution upgrades Python. AppRun explains which Debian packages to install if GTK 4 or libadwaita are missing. Running the AppImage with --instalar (or using the app menu) copies it to ~/.local/bin and adds it to the application menu, to "Open With" and to the context menu of Files. Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- appimage/AppRun | 22 +++++++++++++++ appimage/build.sh | 58 +++++++++++++++++++++++++++++++++++++++ appimage/requirements.txt | 4 +++ 3 files changed, 84 insertions(+) create mode 100755 appimage/AppRun create mode 100755 appimage/build.sh create mode 100644 appimage/requirements.txt 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" < Date: Thu, 15 Oct 2026 05:43:32 +0200 Subject: [PATCH 6/8] docs: document the desktop app, --speed and dual-mode printers Explain how to build and install the AppImage, the new --speed option, how to fix "br-connection-profile-unavailable" on dual-mode printers such as the SC03h, and that blank prints usually mean a low battery. Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f0b2ddc..4b96257 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,52 @@ 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`. + +# 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 From 5598f513b445c5a4550f99c126131c8ad7bd4132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Cort=C3=A9s?= Date: Thu, 15 Oct 2026 05:52:33 +0200 Subject: [PATCH 7/8] feat(web): add a PWA that prints from the browser over Web Bluetooth docs/ is a static web app, ready for GitHub Pages: - js/protocol.js ports catprinter/cmds.py and status.py; its output is byte-for-byte identical to the Python version. - js/printer.js connects over Web Bluetooth with the same behavior as PrinterConnection: 180-byte packets, flow control, the state answer as the "done" signal, battery and state flags, and automatic reconnection. - js/imaging.js ports the GUI's processing: images, PDFs (pdf.js) and text, margin cropping, rotation, dithering and thicker strokes. - The PWA works offline, can be installed, receives files from the Android share sheet and handles "Open With" when installed. - Browsers without Web Bluetooth (Firefox, Safari, iOS, Chrome on Linux without the flag) get a page explaining the alternatives. Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- README.md | 12 + docs/.nojekyll | 0 docs/css/app.css | 380 +++++++++++++++++++++++++++ docs/icons/icon-192.png | Bin 0 -> 6294 bytes docs/icons/icon-512.png | Bin 0 -> 16622 bytes docs/icons/icon-maskable-512.png | Bin 0 -> 13789 bytes docs/icons/icon.svg | 29 +++ docs/index.html | 153 +++++++++++ docs/js/app.js | 432 +++++++++++++++++++++++++++++++ docs/js/imaging.js | 366 ++++++++++++++++++++++++++ docs/js/printer.js | 211 +++++++++++++++ docs/js/protocol.js | 157 +++++++++++ docs/manifest.webmanifest | 44 ++++ docs/sw.js | 85 ++++++ 14 files changed, 1869 insertions(+) create mode 100644 docs/.nojekyll create mode 100644 docs/css/app.css create mode 100644 docs/icons/icon-192.png create mode 100644 docs/icons/icon-512.png create mode 100644 docs/icons/icon-maskable-512.png create mode 100644 docs/icons/icon.svg create mode 100644 docs/index.html create mode 100644 docs/js/app.js create mode 100644 docs/js/imaging.js create mode 100644 docs/js/printer.js create mode 100644 docs/js/protocol.js create mode 100644 docs/manifest.webmanifest create mode 100644 docs/sw.js diff --git a/README.md b/README.md index 4b96257..cc81ade 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,18 @@ 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 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 0000000000000000000000000000000000000000..6bf0da2874d402c44e0ffe729cfa6cc2befbc9bf GIT binary patch literal 6294 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 literal 0 HcmV?d00001 diff --git a/docs/icons/icon-maskable-512.png b/docs/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0bc455d4e0ffb00d20c7fed8e688a62b882ecc78 GIT binary patch literal 13789 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�R#&ehpLY@anuskvX!!k%YXtckGi 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 literal 0 HcmV?d00001 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..6422169 --- /dev/null +++ b/docs/js/app.js @@ -0,0 +1,432 @@ +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 SHARED_CACHE = "catprinter-shared"; + +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()))); + }); + } + + const url = new URL(location.href); + if (!url.searchParams.has("compartido") || !("caches" in window)) return; + history.replaceState(null, "", url.pathname); + const cache = await caches.open(SHARED_CACHE); + const files = []; + for (const request of await cache.keys()) { + const response = await cache.match(request); + const name = decodeURIComponent(response.headers.get("X-File-Name") || "archivo"); + files.push(new File([await response.blob()], name, { type: response.headers.get("Content-Type") })); + await cache.delete(request); + } + if (files.length) openFiles(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..bd71b82 --- /dev/null +++ b/docs/sw.js @@ -0,0 +1,85 @@ +// Service worker: makes the app work offline and receives shared files. +// Bump VERSION whenever the files below change. +const VERSION = "v1"; +const SHELL_CACHE = `catprinter-shell-${VERSION}`; +const RUNTIME_CACHE = "catprinter-runtime"; +const SHARED_CACHE = "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", +]; + +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()) { + if (key.startsWith("catprinter-shell-") && key !== SHELL_CACHE) 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) { + 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 for the page, which reads them on load. +async function receiveShare(request) { + const form = await request.formData(); + const cache = await caches.open(SHARED_CACHE); + const files = form.getAll("archivos").filter((f) => f instanceof File); + await Promise.all(files.map((file, i) => cache.put(`./compartido/${i}`, new Response(file, { + headers: { + "Content-Type": file.type || "application/octet-stream", + "X-File-Name": encodeURIComponent(file.name), + }, + })))); + return Response.redirect("./?compartido", 303); +} From 3d39fc86eca332103d50395e7ce04b6ee44f65cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Cort=C3=A9s?= Date: Thu, 15 Oct 2026 05:54:03 +0200 Subject: [PATCH 8/8] fix(web): never store the files to print Bugs: - The service worker cached every same-origin GET response, not only the app's own files, so other requests ended up stored on disk. - Files shared from other apps were written to a cache until the page read them; if the page never read them, they stayed there. Now only the app shell (and the versioned pdf.js library) is cached. Shared files are kept in the service worker's memory and handed to the page with postMessage. The old shared-files cache is deleted on update. Files opened, dropped or pasted were never stored and still aren't. Changes made by Claude (Claude Code). Co-Authored-By: Claude Opus 5.5 --- docs/js/app.js | 18 +++++++----------- docs/sw.js | 33 ++++++++++++++++++++------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/docs/js/app.js b/docs/js/app.js index 6422169..1289999 100644 --- a/docs/js/app.js +++ b/docs/js/app.js @@ -19,7 +19,6 @@ const LOW_BATTERY = 20; const DOTS_PER_MM = 8; const KEEP_ALIVE_MS = 60000; const REPROCESS_DELAY_MS = 250; -const SHARED_CACHE = "catprinter-shared"; const $ = (id) => document.getElementById(id); @@ -402,18 +401,15 @@ async function receiveLaunchedFiles() { }); } + // 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") || !("caches" in window)) return; + if (!url.searchParams.has("compartido") || !("serviceWorker" in navigator)) return; history.replaceState(null, "", url.pathname); - const cache = await caches.open(SHARED_CACHE); - const files = []; - for (const request of await cache.keys()) { - const response = await cache.match(request); - const name = decodeURIComponent(response.headers.get("X-File-Name") || "archivo"); - files.push(new File([await response.blob()], name, { type: response.headers.get("Content-Type") })); - await cache.delete(request); - } - if (files.length) openFiles(files); + 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 diff --git a/docs/sw.js b/docs/sw.js index bd71b82..5404b9c 100644 --- a/docs/sw.js +++ b/docs/sw.js @@ -1,9 +1,10 @@ // 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 = "v1"; +const VERSION = "v2"; const SHELL_CACHE = `catprinter-shell-${VERSION}`; const RUNTIME_CACHE = "catprinter-runtime"; -const SHARED_CACHE = "catprinter-shared"; +const LEGACY_CACHES = ["catprinter-shared"]; const SHELL_FILES = [ "./", @@ -19,6 +20,10 @@ const SHELL_FILES = [ "./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))); @@ -28,7 +33,8 @@ self.addEventListener("install", (event) => { self.addEventListener("activate", (event) => { event.waitUntil((async () => { for (const key of await caches.keys()) { - if (key.startsWith("catprinter-shell-") && key !== SHELL_CACHE) await caches.delete(key); + const old = key.startsWith("catprinter-shell-") && key !== SHELL_CACHE; + if (old || LEGACY_CACHES.includes(key)) await caches.delete(key); } await self.clients.claim(); })()); @@ -38,7 +44,8 @@ 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) { + } 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. @@ -70,16 +77,16 @@ async function cacheFirst(request) { return response; } -// Share target: keeps the shared files for the page, which reads them on load. +// 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(); - const cache = await caches.open(SHARED_CACHE); - const files = form.getAll("archivos").filter((f) => f instanceof File); - await Promise.all(files.map((file, i) => cache.put(`./compartido/${i}`, new Response(file, { - headers: { - "Content-Type": file.type || "application/octet-stream", - "X-File-Name": encodeURIComponent(file.name), - }, - })))); + 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 = []; +});