Refactor Canvas with new revert(), text(), fill() methods

This commit is contained in:
Yusuf Cihan 2025-04-17 17:53:26 +03:00
parent 3e47b0116e
commit fe3cf155e3
No known key found for this signature in database
GPG Key ID: 05F795C0B9E24D48
5 changed files with 379 additions and 159 deletions

View File

@ -29,7 +29,7 @@ This project depends on [`bleak`](https://pypi.org/project/bleak/) for cross-pla
## Installation ## Installation
``` ```
python -m pip install --upgrade https://github.com/ysfchn/dymo-bluetooth/archive/refs/heads/main.zip python -m pip install --upgrade "https://github.com/ysfchn/dymo-bluetooth/archive/refs/heads/main.zip"
``` ```
Python 3.10 or up is targeted, but 3.9 should work too. It depends on; Python 3.10 or up is targeted, but 3.9 should work too. It depends on;
@ -41,7 +41,7 @@ Additionally, if `python-barcode` is installed (which is optional), it can be al
## Usage ## Usage
There is a small CLI to print image files to a nearby printer. There is a CLI provided with the module to print images to the first found printer nearby.
``` ```
python -m dymo_bluetooth --help python -m dymo_bluetooth --help
@ -63,18 +63,26 @@ async def main():
# Images needs to be stretched by at least 2 times like # Images needs to be stretched by at least 2 times like
# how its mobile app does, otherwise printer will render # how its mobile app does, otherwise printer will render
# the labels too narrow. # the labels too narrow.
canvas = canvas.stretch_image(2) canvas = canvas.stretch(2)
# Get a list of printers. # Get a list of printers.
printers = await discover_printers() printers = await discover_printers()
# Get the first found printer and print the # Get the first discovered printer and print the
# constructed Canvas. Returns the print status. # constructed Canvas. Returns the print status.
await printers[0].print(canvas) await printers[0].print(canvas)
asyncio.run(main()) asyncio.run(main())
``` ```
## Notes
Some things to note while using the printer:
* The printer won't print anything if the lid is open, even if it may seem that it actually printed.
* If it doesn't print, make sure that all four batteries hold high enough charge, not a single battery should be low.
* If you hold the power button while the printer is on, it will automatically print its MAC address.
## License & Disclaimer ## License & Disclaimer
This program is licensed under [MIT License](./LICENSE). This program is licensed under [MIT License](./LICENSE).
@ -87,8 +95,8 @@ This program is not supported, sponsored, affiliated, approved, or endorsed in a
This printer uses Bluetooth LE (GATT) to send labels & retrieve its status. It does uses this service UUIDs: This printer uses Bluetooth LE (GATT) to send labels & retrieve its status. It does uses this service UUIDs:
| UUID | Used for | | UUID | Used for |
|:------:|:-------:| |:------:|:--------:|
| `be3dd650-2b3d-42f1-99c1-f0f749dd0678` | Service | | `be3dd650-2b3d-42f1-99c1-f0f749dd0678` | Service |
| `be3dd651-2b3d-42f1-99c1-f0f749dd0678` | [Print request (write -> printer)](#print-request) | | `be3dd651-2b3d-42f1-99c1-f0f749dd0678` | [Print request (write -> printer)](#print-request) |
| `be3dd652-2b3d-42f1-99c1-f0f749dd0678` | [Print reply (notify <- printer)](#result) | | `be3dd652-2b3d-42f1-99c1-f0f749dd0678` | [Print reply (notify <- printer)](#result) |
@ -125,7 +133,7 @@ Below is the structure of each command that follows after its directive:
#### `START` command #### `START` command
Each payload starts with directive. This command follows with 4 constant bytes after its directive. Each payload starts with directive. This command follows with 4 constant bytes (`9A 02 00 00`) after its directive.
``` ```
START[6] = DIRECTIVE[2] + 9A 02 00 00 START[6] = DIRECTIVE[2] + 9A 02 00 00
@ -133,7 +141,7 @@ START[6] = DIRECTIVE[2] + 9A 02 00 00
#### `MEDIA_TYPE` command #### `MEDIA_TYPE` command
This command follows with 1 byte containing a number of some type (?) to be set for the printer. It doesn't really seem to be used by this printer, but this command was found in its mobile app anyway. This command follows with 1 byte containing a number of some type (?) to be set for the printer. It doesn't really seem to be used by this printer, but this command was discovered in its mobile app anyway.
When this number has set to anything, the printer should advertise this number when searching for Bluetooth devices nearby. (not tested) So I guess it was planned to be used for some sort of casette checking & changing casette type, so printer can hold this value even when its turned off. When this number has set to anything, the printer should advertise this number when searching for Bluetooth devices nearby. (not tested) So I guess it was planned to be used for some sort of casette checking & changing casette type, so printer can hold this value even when its turned off.
@ -292,12 +300,23 @@ This payload doesn't require chunking and can be sent with single Bluetooth tran
Printer will notify on the [print reply UUID](#protocol), giving the status of the printing operation. First byte is always an ASCII escape character `1B` (in hexadecimal, or 27 in decimal), and second byte is the ASCII code point of the `R` character (`0x52` in hexadecimal, or 82 in decimal) that stands for the result. Printer will notify on the [print reply UUID](#protocol), giving the status of the printing operation. First byte is always an ASCII escape character `1B` (in hexadecimal, or 27 in decimal), and second byte is the ASCII code point of the `R` character (`0x52` in hexadecimal, or 82 in decimal) that stands for the result.
Third byte represents the status code. The printer may be inconsistent when it comes to the status codes, see `Result` enum in the code for more details about available status codes and their (supposedly) meanings. Third byte represents the status code. The printer is inconsistent when it comes to the status codes, so we can't be always fully sure if it actually did print. But the printer has several status codes anyways for some specific situations.
``` ```
RESULT[3] = 1B 52 + STATUS[1] RESULT[3] = 1B 52 + STATUS[1]
``` ```
## Extras #### Status codes
The printer will automatically print its MAC address if power button was pressed long. | Value | Did print? | Meaning |
|:-------:|:-------------:|:----------|
| `0` | Maybe* | Printing has completed. |
| `1` | Yes | Printing has completed (or still ongoing?). |
| `2` | No | Failed for an unknown reason. |
| `3` | Yes | Printed but the battery is low, probably won't be able to print again with same batteries. |
| `4` | No | Failed to print because the operation was cancelled. |
| `5` | No | Failed for an unknown reason, like `2`. Don't really know when it does return that. |
| `6` | No | Failed to print because the battery is low. |
| `7` | No | Failed to print because casette was not inserted. On my tests, printer never sends this status, instead it will spin its gear even casette is not inserted and sends `0` instead. |
\* It is a "maybe", because for some cases, printer tells it did print even when it actually didn't. (e.g. the casette was not inserted, or the lid is open)

View File

@ -22,28 +22,46 @@
from argparse import ArgumentParser, BooleanOptionalAction from argparse import ArgumentParser, BooleanOptionalAction
from pathlib import Path from pathlib import Path
from typing import cast from typing import Literal, Union, cast
from dymo_bluetooth.bluetooth import discover_printers, create_image from dymo_bluetooth.bluetooth import discover_printers, create_image
import sys import sys
import asyncio import asyncio
import os import os
async def print_image( async def print_image(
input_file : Path, input_file: Path,
max_timeout : int, max_timeout: int,
stretch_factor : int, stretch_factor: int,
use_dither : bool use_dither: bool,
ensure_mac: bool,
padding: int,
reverse: bool,
is_preview: Union[None, Literal["large", "small"]]
): ):
canvas = create_image(input_file, dither = use_dither) canvas = create_image(input_file, dither = use_dither)
canvas = canvas.stretch_image(factor = stretch_factor) if stretch_factor != 1:
printers = await discover_printers(max_timeout) canvas = canvas.stretch(factor = stretch_factor)
if padding != 0:
canvas = canvas.fill(padding, padding)
if reverse:
canvas = canvas.revert()
if is_preview:
print(canvas.text(False if is_preview == "large" else True))
exit(0)
print(f"Image size: {canvas.size}", file = sys.stderr)
print(f"Searching for nearby printers to print (timeout: {max_timeout})...", file = sys.stderr)
printers = await discover_printers(max_timeout, ensure_mac)
if not printers: if not printers:
print("Couldn't find any printers in given timeout!") print("Couldn't find any printers, is the printer online?", file = sys.stderr)
exit(1) exit(1)
printer = printers[0] printer = printers[0]
print(f"Starting to print on {printer._impl.address}...") print(f"Found: {printer._impl.address}", file = sys.stderr)
await printer.connect() await printer.connect()
await printer.print(canvas) print("Printing...", file = sys.stderr)
result = await printer.print(canvas)
print(f"Result: {result.name} ({result.value})", file = sys.stderr)
def main(): def main():
@ -51,28 +69,107 @@ def main():
args = ArgumentParser( args = ArgumentParser(
prog = f"python -m {module_name}", prog = f"python -m {module_name}",
description = ( description = (
"Print monochrome labels with DYMO LetraTag LT-200B label printer over " "Print monochrome labels with DYMO LetraTag LT-200B label printer over Bluetooth. "
"Bluetooth." "If executed without --preview, it will search for a printer nearby and automatically "
"start printing the image."
) )
) )
args.add_argument("image", help = "Image file to print. Must be 32 pixels in height.", type = Path, metavar = "IMAGE") # noqa: E501 args.add_argument(
args.add_argument("--timeout", default = 5, type = int, help = "Maximum timeout to search for printers.") # noqa: E501 "image",
args.add_argument("--dither", default = True, action = BooleanOptionalAction, help = (
help = "Use or don't use dithering when converting it to monochrome image." "Image file to print. Can be any type of image that is supported by Pillow. "
"Must be 32 pixels in height, otherwise it will be cropped out."
),
type = Path,
metavar = "IMAGE"
) )
args.add_argument("--factor", default = 2, type = int, args.add_argument(
help = "Stretch the image N times. Default is 2, otherwise printer" + \ "--ensure-mac",
"will print images too thin." default = 5,
action = "store_true",
help = (
"Also ensures the MAC address does match with the pre-defined MAC prefixes when "
"searching for a printer. Otherwise it will only search for printers by "
"looking for service UUID and its Bluetooth name."
)
)
args.add_argument(
"--timeout",
default = 5,
type = int,
help = "Maximum timeout in seconds to search for printers."
)
args.add_argument(
"--dither",
default = True,
action = BooleanOptionalAction,
help = (
"Use or don't use Floyd-Steinberg dithering provided by Pillow when converting "
"the image to a monochrome image."
)
)
args.add_argument(
"--stretch",
default = 2,
type = int,
help = (
"Stretch the image N times. Default is 2 (as-is in the mobile app), otherwise "
"the printed label will become too thin to actually read."
)
)
args.add_argument(
"--padding",
default = 0,
type = int,
help = (
"Adds N blank width in both sides of the image, leaving the content in the center. "
"So the output width will be ((N * 2) + image width)."
)
)
args.add_argument(
"--reverse",
action = "store_true",
help = (
"Flip the image in color (black becomes white, white becomes black)."
)
)
args.add_argument(
"--preview",
const = "small",
nargs = "?",
choices = ("large", "small"),
help = (
"Don't actually print anything, just print the image in the console with all settings "
"applied to it. The default is 'small', which is the recommended option."
)
) )
parsed = args.parse_args() parsed = args.parse_args()
input_file = cast(Path, parsed.image).absolute() input_file = cast(Path, parsed.image).absolute()
max_timeout = cast(int, parsed.timeout) max_timeout = cast(int, parsed.timeout)
stretch_factor = cast(int, parsed.factor) stretch_factor = cast(int, parsed.stretch)
use_dither = cast(bool, parsed.dither) use_dither = cast(bool, parsed.dither)
ensure_mac = cast(bool, parsed.ensure_mac)
padding = cast(int, parsed.padding)
reverse = cast(bool, parsed.reverse)
is_preview = cast(Union[None, Literal["large", "small"]], parsed.preview)
if not input_file.exists(): if not input_file.exists():
print(f"Given image file doesn't exists: {input_file.as_posix()}") print(f"Input file doesn't exists: {input_file.as_posix()}", file = sys.stderr)
exit(1) exit(1)
asyncio.run(print_image(input_file, max_timeout, stretch_factor, use_dither)) if input_file.is_dir():
print(f"Input file can't be a directory: {input_file.as_posix()}", file = sys.stderr)
exit(1)
asyncio.run(print_image(
input_file,
max_timeout,
stretch_factor,
use_dither,
ensure_mac,
padding,
reverse,
is_preview
))
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -23,6 +23,7 @@
import asyncio import asyncio
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from sys import stderr
from PIL import Image, ImageChops from PIL import Image, ImageChops
from bleak import BleakScanner, BleakClient from bleak import BleakScanner, BleakClient
from typing import List, TYPE_CHECKING from typing import List, TYPE_CHECKING
@ -41,16 +42,14 @@ PRINT_REPLY_UUID = "be3dd652-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d"
UNKNOWN_UUID = "be3dd653-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d") UNKNOWN_UUID = "be3dd653-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d")
def is_espressif(input_mac : str): def is_espressif(input_mac: str):
""" """
Returns True if given MAC address is from a valid DYMO printer. Returns True if given MAC address is from Espressif Inc.
The mac address blocks are owned by Espressif Inc.
""" """
mac_blocks = [ mac_blocks = [
"58:CF:79", "58:CF:79",
# Confirmed in pull request #2 "DC:54:75", # confirmed in #2
"DC:54:75", "34:85:18", # confirmed in #4
"34:85:18"
] ]
check_mac = int(input_mac.replace(":", ""), base = 16) check_mac = int(input_mac.replace(":", ""), base = 16)
for mac in mac_blocks: for mac in mac_blocks:
@ -62,7 +61,7 @@ def is_espressif(input_mac : str):
class Printer: class Printer:
def __init__(self, impl : "BLEDevice") -> None: def __init__(self, impl: "BLEDevice") -> None:
self._impl = impl self._impl = impl
self._client = BleakClient(self._impl) self._client = BleakClient(self._impl)
@ -76,16 +75,16 @@ class Printer:
return return
await self._client.disconnect() await self._client.disconnect()
async def print(self, canvas : Canvas): async def print(self, canvas: Canvas):
if not self._client.is_connected: if not self._client.is_connected:
raise Exception("Printer is not connected!") raise Exception("Printer is not connected!")
print_request : "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REQUEST_UUID) # type: ignore # noqa: E501 print_request: "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REQUEST_UUID) # type: ignore # noqa: E501
print_reply : "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REPLY_UUID) # type: ignore # noqa: E501 print_reply: "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REPLY_UUID) # type: ignore # noqa: E501
future : asyncio.Future[Result] = asyncio.Future() future: asyncio.Future[Result] = asyncio.Future()
should_discard : bool = False should_discard: bool = False
# Printer sends two messages, first is the PRINTING, and second one is the # Printer sends two messages, first is the PRINTING, and second one is the
# printing result. So we discard the first message. # printing result. So we discard the first message.
async def reply_get(_, data : bytearray): # noqa: E501 async def reply_get(_, data: bytearray): # noqa: E501
nonlocal should_discard nonlocal should_discard
result = Result.from_bytes(data) result = Result.from_bytes(data)
if (not should_discard) and (result.value in [0, 1]): if (not should_discard) and (result.value in [0, 1]):
@ -99,24 +98,27 @@ class Printer:
return await future return await future
async def discover_printers(max_timeout : int = 5) -> List[Printer]: async def discover_printers(max_timeout: int = 5, ensure_mac: bool = False) -> List[Printer]:
""" """
Searches for printers nearby and returns a list of Printer objects. If no printer Searches for printers nearby and returns a list of Printer objects. If no printer
has found in the initial search, waits for scanning until the max timeout has been has found in the initial search, waits for scanning until the max timeout has been
reached. reached.
""" """
printers : List[Printer] = [] printers: List[Printer] = []
waited_total = 0 waited_total = 0
async with BleakScanner(service_uuids = [SERVICE_UUID]) as scanner: async with BleakScanner(service_uuids = [SERVICE_UUID]) as scanner:
while True: while True:
# TODO: In some cases, advetisement data may be non-null, containing # TODO: In some cases, advetisement data may be non-null, containing
# additional metadata about printer state but it is not implemented yet. # additional metadata about printer state but it is not implemented yet.
for device, _ in scanner.discovered_devices_and_advertisement_data.values(): # noqa: E501 for device, _ in scanner.discovered_devices_and_advertisement_data.values(): # noqa: E501
if not is_espressif(device.address):
continue
# Also match the "Letratag" name just to be sure that we are looking for
# the right device, since there are also other products made by DYMO.
if (device.name or "") == f"Letratag {device.address.replace(':', '')}": if (device.name or "") == f"Letratag {device.address.replace(':', '')}":
if ensure_mac and (not is_espressif(device.address)):
print(
f"A possible printer is found, but its MAC {device.address} isn't whitelisted, " +
"thus ignored. If it isn't right, either disable MAC checking or open a issue.",
file = stderr
)
continue
printers.append(Printer(device)) printers.append(Printer(device))
# Do we have any candidate printers? If so, return the found printers. # Do we have any candidate printers? If so, return the found printers.
# Otherwise, wait for the next scans until we found any. # Otherwise, wait for the next scans until we found any.
@ -130,9 +132,9 @@ async def discover_printers(max_timeout : int = 5) -> List[Printer]:
def convert_image_to_canvas( def convert_image_to_canvas(
image : Image.Image, image: Image.Image,
dither : bool = True, dither: bool = True,
trim : bool = False trim: bool = False
): ):
""" """
Converts an Pillow Image to a Canvas object. Converts an Pillow Image to a Canvas object.
@ -147,14 +149,15 @@ def convert_image_to_canvas(
output = output.crop(diff.getbbox(alpha_only = False)) output = output.crop(diff.getbbox(alpha_only = False))
# Shrink the image from the center if it exceeds the print height, # Shrink the image from the center if it exceeds the print height,
# or max printable width. # or max printable width.
if (output.height > Canvas.HEIGHT): canvas_height = Canvas.BYTES_PER_LINE * 8
start_y = int(output.height / 2) - int(Canvas.HEIGHT / 2) if (output.height > canvas_height):
start_y = int(output.height / 2) - int(canvas_height / 2)
output = output.crop( output = output.crop(
(0, start_y, output.width, start_y + Canvas.HEIGHT) (0, start_y, output.width, start_y + canvas_height)
) )
elif (output.height < Canvas.HEIGHT): elif (output.height < canvas_height):
raise ValueError("Image is too small, resizing not implemented.") raise ValueError("Image is too small, resizing not implemented.")
if (output.width) > Canvas.MAX_WIDTH: if (output.width) > Canvas.MAX_LENGTH:
raise ValueError("Image is too large, resizing not implemented.") raise ValueError("Image is too large, resizing not implemented.")
# Convert image to pixel array. # Convert image to pixel array.
canvas = Canvas() canvas = Canvas()
@ -165,7 +168,7 @@ def convert_image_to_canvas(
return canvas return canvas
def create_image(path : Path, dither : bool = True): def create_image(path: Path, dither: bool = True):
""" """
Converts an image file in given path to Canvas. Converts an image file in given path to Canvas.
""" """
@ -177,7 +180,7 @@ def create_image(path : Path, dither : bool = True):
return convert_image_to_canvas(image, dither) return convert_image_to_canvas(image, dither)
def create_code_128(text : str): def create_code_128(text: str):
""" """
Creates a Code 128 barcode and dumps to Canvas. Creates a Code 128 barcode and dumps to Canvas.
""" """

View File

@ -22,12 +22,9 @@
from enum import Enum from enum import Enum
from io import SEEK_END, BytesIO from io import SEEK_END, BytesIO
from typing import Union from typing import Literal, Sequence, Tuple
import math import math
BytesLike = Union[bytes, bytearray]
class DirectiveCommand(Enum): class DirectiveCommand(Enum):
""" """
Directives that accepted by the printer. Directives that accepted by the printer.
@ -75,7 +72,7 @@ class Result(Enum):
FAILED_NO_CASETTE = 7 FAILED_NO_CASETTE = 7
@classmethod @classmethod
def from_bytes(cls, data : BytesLike): def from_bytes(cls, data: Sequence[int]):
if data[0] != 27: if data[0] != 27:
raise ValueError("Not a valid result value; 1st byte must be 0x1b (27)") raise ValueError("Not a valid result value; 1st byte must be 0x1b (27)")
if chr(data[1]) != "R": if chr(data[1]) != "R":
@ -93,149 +90,142 @@ class Result(Enum):
class Canvas: class Canvas:
""" """
An implementation of 1-bit monochrome little-endian encoded 32 pixel height An implementation of 1-bit monochrome horizontal images, sequentially
images for printing them to the label. ordered and stored in big-endian for each pixel.
1 byte equals 8 bits, and each bit specifies if pixel should be black (= 1) 1 byte equals 8 bits, and each bit specifies if pixel should be filled
or white (= 0). The generated image will contain a multiple of 4 bytes, equaling (= 1) or not (= 0).
the label height in pixels (4 * 8 = 32 pixels).
""" """
__slots__ = ("buffer", ) __slots__ = ("buffer", )
HEIGHT = 32 # Each line takes 4 bytes, equals to 32 pixels (1 byte = 8 bits).
MAX_WIDTH = 8186 BYTES_PER_LINE = 4
# Maximum count of bytes that the image can extend into the fixed direction.
MAX_LENGTH = 1024
def __init__(self) -> None: def __init__(self) -> None:
self.buffer = BytesIO() self.buffer = BytesIO()
def get_pixel(self, x : int, y : int): def get_pixel(self, x: int, y: int) -> bool:
""" """
Gets the pixel in given coordinates. Gets the pixel in given coordinates.
Returns True if pixel is black, otherwise False. Returns True if pixel is filled (= black), otherwise False.
""" """
if y >= Canvas.HEIGHT: self._raise_if_out_bounds(x, y)
raise ValueError(f"Canvas can't exceed {Canvas.HEIGHT} pixels in height.")
if x >= Canvas.MAX_WIDTH:
raise ValueError(f"Canvas can't exceed {Canvas.MAX_WIDTH} pixels in width.")
# Get the byte containing the pixel value of given coordinates. # Get the byte containing the pixel value of given coordinates.
x_offset = math.ceil((x * Canvas.HEIGHT) / 8) x_offset = x * Canvas.BYTES_PER_LINE
y_offset = ((self.HEIGHT) // 8) - 1 - math.floor(y / 8) y_offset = Canvas.BYTES_PER_LINE - 1 - math.floor(y / 8)
self.buffer.seek(x_offset + y_offset) self.buffer.seek(x_offset + y_offset)
# Check if there is a bit value in given line. # Check if there is a bit value in given line.
read = self.buffer.read(1) value = (self.buffer.read(1) or b"\x00")[0]
value = int.from_bytes(read or b"\x00", "little")
is_black = bool(value & (1 << (7 - (y % 8)))) is_black = bool(value & (1 << (7 - (y % 8))))
return is_black return is_black
def set_pixel(self, x : int, y : int, color : bool): def set_pixel(self, x: int, y: int, color: Literal[True, False, 0, 1]) -> None:
""" """
Sets a pixel to given coordinates. Sets a pixel to given coordinates.
Setting color to True will paint a black color. Setting color to True will paint a black color.
""" """
if y >= Canvas.HEIGHT: self._raise_if_out_bounds(x, y)
raise ValueError(f"Canvas can't exceed {Canvas.HEIGHT} pixels in height.")
if x >= Canvas.MAX_WIDTH:
raise ValueError(f"Canvas can't exceed {Canvas.MAX_WIDTH} pixels in width.")
# Get the byte containing the pixel value of given coordinates. # Get the byte containing the pixel value of given coordinates.
x_offset = math.ceil((x * Canvas.HEIGHT) / 8) x_offset = x * Canvas.BYTES_PER_LINE
y_offset = ((self.HEIGHT) // 8) - 1 - math.floor(y / 8) y_offset = Canvas.BYTES_PER_LINE - 1 - math.floor(y / 8)
self.buffer.seek(x_offset + y_offset) self.buffer.seek(x_offset + y_offset)
# Get the one of four slices in line in the given coordinates. Add the bit in # Get the one of four slices in line in the given coordinates. Add the bit in
# given location if color is black, otherwise exclude the bit to make it white. # given location if color is black, otherwise exclude the bit to make it white.
read = self.buffer.read(1) curr = self.buffer.read(1)
value = int.from_bytes(read or b"\x00", "little") value = (curr or b"\x00")[0]
if color: if color:
value = value | (1 << (7 - (y % 8))) value = value | (1 << (7 - (y % 8)))
else: else:
value = value & ~(1 << (7 - (y % 8))) value = value & ~(1 << (7 - (y % 8)))
# Change the current part of the line with modified one. # Change the current byte with modified one, if not exists, append a new one.
self.buffer.seek(x_offset + y_offset) self.buffer.seek(0 if not curr else -1, 1)
self.buffer.write(bytes([value])) self.buffer.write(bytes([value]))
def stretch_image(self, factor : int = 2): def stretch(self, factor: int = 2) -> "Canvas":
""" """
Stretches image to the right in N times, and returns a new Canvas Stretches image to the non-fixed direction in N times, and returns a new
containing the stretched image. Factor 1 results in the same image. Canvas containing the stretched image. Factor 1 results in the same image.
""" """
if factor < 1: if factor < 1:
raise ValueError("Stretch factor must be at least 1!") raise ValueError("Stretch factor must be at least 1!")
canvas = Canvas() canvas = Canvas()
for x in range(self.get_width()): for x in range(self.width):
for y in range(Canvas.HEIGHT): for y in range(self.height):
pixel = self.get_pixel(x, y) pixel = self.get_pixel(x, y)
start_x = (x * factor) start_x = (x * factor)
for i in range(factor): for i in range(factor):
canvas.set_pixel(start_x + i, y, pixel) canvas.set_pixel(start_x + i, y, pixel)
return canvas return canvas
def pad_image(self, width : int): def _get_byte_size(self):
"""
Adds blank spacing to both sides of the image, until the image
reaches to the given width. Returns a new Canvas containing the
modified image. If image is already longer than given width, it
will return the current Canvas.
"""
current_width = self.get_width()
if current_width >= width:
return self
canvas = Canvas()
left_padding = math.ceil(width / 2)
for x in range(current_width):
for y in range(Canvas.HEIGHT):
canvas.set_pixel(left_padding + x, y, self.get_pixel(x, y))
for i in range(Canvas.HEIGHT):
canvas.set_pixel(current_width + left_padding, i, False)
return canvas
def print(self):
"""
Prints the image to the console.
"""
for y in range(Canvas.HEIGHT):
for x in range(self.get_width()):
print("" if self.get_pixel(x, y) else "", end = "")
print()
def get_width(self):
"""
Gets the painted width of the image.
"""
self.buffer.seek(0, SEEK_END) self.buffer.seek(0, SEEK_END)
cur = self.buffer.tell() return self.buffer.tell()
return math.ceil(cur / (self.HEIGHT // 8))
def get_size(self): def _get_unfixed_pixels(self):
return math.ceil(self._get_byte_size() / self.BYTES_PER_LINE)
def _get_fixed_pixels(self):
return self.BYTES_PER_LINE * 8
def _raise_if_out_bounds(self, x: int, y: int):
if (x < 0) or (y < 0):
raise ValueError("Canvas positions can't be negative.")
bits = Canvas.BYTES_PER_LINE * 8
maxbits = Canvas.MAX_LENGTH * 8
if y >= bits:
raise ValueError(f"Canvas can't be or exceed {bits} pixels in height.")
elif x >= maxbits:
raise ValueError(f"Canvas can't be or exceed {maxbits} pixels in width.")
@property
def height(self) -> int:
"""
Gets the height of the image.
"""
return self._get_fixed_pixels()
@property
def width(self) -> int:
"""
Gets the width of the image.
"""
return self._get_unfixed_pixels()
@property
def size(self) -> Tuple[int, int]:
""" """
Gets the width and height of the image in tuple. Gets the width and height of the image in tuple.
""" """
return (self.get_width(), Canvas.HEIGHT, ) return (self.width, self.height, )
def get_image(self): def get_image(self) -> bytes:
""" """
Gets the created image with added blank padding. Gets the created image with added blank padding.
""" """
self.buffer.seek(0) self.buffer.seek(0)
image = self.buffer.read() image = self.buffer.read()
return image + (b"\x00" * (self.buffer.tell() % (self.HEIGHT // 8))) return image + (b"\x00" * (self.buffer.tell() % self.BYTES_PER_LINE))
def empty(self): def empty(self):
""" """
Makes all pixels in the canvas in white. Canvas size won't be changed. Makes all pixels in the canvas in blank (= white). Canvas size won't be changed.
""" """
self.buffer.seek(0, SEEK_END) size = self._get_byte_size()
byte_count = self.buffer.tell()
self.buffer.seek(0) self.buffer.seek(0)
self.buffer.truncate(0) self.buffer.truncate(0)
self.buffer.seek(byte_count) self.buffer.seek(size)
self.buffer.write(b"\x00") self.buffer.write(b"\x00")
def copy(self): def copy(self) -> "Canvas":
""" """
Creates a copy of this canvas. Creates a copy of this canvas.
""" """
@ -251,16 +241,125 @@ class Canvas:
self.buffer.seek(0) self.buffer.seek(0)
self.buffer.truncate(0) self.buffer.truncate(0)
def revert(self):
"""
Returns a new Canvas with the image is flipped in color; all unfilled
pixels are filled, and all filled pixels are unfilled.
"""
self.buffer.seek(0)
copied = Canvas()
copied.buffer.seek(0)
while (byt := self.buffer.read(1)):
copied.buffer.write(bytes([byt[0] ^ 0xFF]))
return copied
def fill(self, to_left: int, to_right: int) -> "Canvas":
"""
Returns a new Canvas with blank (= white) spacing added to both sides.
"""
canv = Canvas()
canv.buffer.seek(0)
canv.buffer.write(bytes(to_left * self.BYTES_PER_LINE))
canv.buffer.write(self.get_image())
canv.buffer.seek(0, SEEK_END)
canv.buffer.write(bytes(to_right * self.BYTES_PER_LINE))
return canv
def pad(self, until: int) -> "Canvas":
"""
Adds blank (= white) spacing to both sides of the image, until the image reaches
to the given width in pixels. Returns a new Canvas containing the modified image.
If image is already longer than given width, returns the copy of the same image.
"""
if self.width >= until:
return self.copy()
side = math.ceil((until - self.width) / 2)
return self.fill(side, side)
def text(self, in_quad: bool = True, blank_char: int = 0x20, frame: bool = True) -> str:
"""
Converts the image to a string of unicode block symbols, so it can be printed
out to the terminal. If `in_quad` is True, quarter block characters will be used,
so the image will be 2 times smaller, making each 4 pixel to take up a single
character. Empty pixels will be represented as `blank_char` (default is SPACE).
"""
lines = []
frame_length = 0
if frame:
frame_length = self.width if not in_quad else math.ceil(self.width / 2)
if frame_length:
lines.append(chr(0x250C) + (frame_length * chr(0x2500)) + chr(0x2510))
if in_quad:
for h in range(0, self.height, 2):
line = ""
for w in range(0, self.width, 2):
corners = \
self.get_pixel(w, h), \
self.get_pixel(w + 1, h), \
self.get_pixel(w, h + 1), \
self.get_pixel(w + 1, h + 1)
corners = sum([1 << x if corners[x] else 0 for x in range(4)])
line += chr(quartet_to_char(corners) or blank_char)
if frame:
line = chr(0x2502) + line + chr(0x2502)
lines.append(line)
else:
lines = []
for h in range(0, self.height):
line = ""
for w in range(0, self.width):
line += chr(0x2588 if self.get_pixel(w, h) else blank_char)
if frame:
line = chr(0x2502) + line + chr(0x2502)
lines.append(line)
if frame_length:
lines.append(chr(0x2514) + (frame_length * chr(0x2500)) + chr(0x2518))
return "\n".join(lines)
def __len__(self):
return self._get_byte_size()
def __str__(self) -> str:
return self.text(in_quad = True)
def __eq__(self, value: object, /) -> bool: def __eq__(self, value: object, /) -> bool:
if not isinstance(value, Canvas): if not isinstance(value, Canvas):
return False return False
return value.get_image() == self.get_image() return value.get_image() == self.get_image()
def __repr__(self) -> str: def __repr__(self) -> str:
self.buffer.seek(0, SEEK_END) w, h = self.size
byte_size = self.buffer.tell() return f"<{self.__class__.__name__} size={w}x{h} length={self.__len__()}>"
image_size = "x".join(map(str, self.get_size()))
return f"<{self.__class__.__name__} image={image_size} bytes={byte_size}>"
def quartet_to_char(char: int):
"""
Gets a unicode block symbol code for a 4 bit value (0x0 to 0xF), each bit representing
a corner of the 4 pixels, top left (1 << 0), top right (1 << 1), bottom left (1 << 2)
and bottom right (1 << 3) respectively.
"""
if (char > 0xF) or (char < 0x0):
raise ValueError("Invalid character.")
# https://en.wikipedia.org/wiki/Block_Elements
data = {
0x0: 0x0000, # Blank
0x1: 0x2598, # 0001
0x2: 0x259D, # 0010
0x3: 0x2580, # 0011
0x4: 0x2596, # 0100
0x5: 0x258C, # 0101
0x6: 0x259E, # 0110
0x7: 0x259B, # 0111
0x8: 0x2597, # 1000
0x9: 0x259A, # 1001
0xA: 0x2590, # 1010
0xB: 0x259C, # 1011
0xC: 0x2584, # 1100
0xD: 0x2599, # 1101
0xE: 0x259F, # 1110
0xF: 0x2588, # 1111
}
return data[char]
class DirectiveBuilder: class DirectiveBuilder:
@ -277,7 +376,7 @@ class DirectiveBuilder:
return bytes([*DirectiveCommand.START.to_bytes(), 154, 2, 0, 0]) return bytes([*DirectiveCommand.START.to_bytes(), 154, 2, 0, 0])
@staticmethod @staticmethod
def media_type(value : int): def media_type(value: int):
return bytes([*DirectiveCommand.MEDIA_TYPE.to_bytes(), value]) return bytes([*DirectiveCommand.MEDIA_TYPE.to_bytes(), value])
@staticmethod @staticmethod
@ -294,14 +393,15 @@ class DirectiveBuilder:
@staticmethod @staticmethod
def print( def print(
data : BytesLike, data: Sequence[int],
image_width : int, image_width: int,
bits_per_pixel : int, image_height: int,
alignment : int bits_per_pixel: int,
alignment: int
): ):
size = \ size = \
image_width.to_bytes(4, "little") + \ image_width.to_bytes(4, "little") + \
Canvas.HEIGHT.to_bytes(4, "little") image_height.to_bytes(4, "little")
return bytes([ return bytes([
*DirectiveCommand.PRINT_DATA.to_bytes(), *DirectiveCommand.PRINT_DATA.to_bytes(),
bits_per_pixel, bits_per_pixel,
@ -311,7 +411,7 @@ class DirectiveBuilder:
]) ])
def create_payload(data : BytesLike, is_print : bool = False): def create_payload(data: Sequence[int], is_print: bool = False):
""" """
Creates a final payload to be sent to the printer from the input data. Creates a final payload to be sent to the printer from the input data.
@ -347,7 +447,7 @@ def create_payload(data : BytesLike, is_print : bool = False):
# Split chunk in each 500 bytes. # Split chunk in each 500 bytes.
for index, step in enumerate(range(0, len(data), CHUNK_SIZE)): for index, step in enumerate(range(0, len(data), CHUNK_SIZE)):
current_chunk = bytearray() current_chunk = bytearray()
chunk = data[step : step + CHUNK_SIZE] chunk = data[step: step + CHUNK_SIZE]
# TODO: Not sure what is the purpose of the this, but the original # TODO: Not sure what is the purpose of the this, but the original
# vendor app skips this index, so we do the same here. # vendor app skips this index, so we do the same here.
chunk_index = index + 1 if index >= 27 else index chunk_index = index + 1 if index >= 27 else index
@ -360,7 +460,7 @@ def create_payload(data : BytesLike, is_print : bool = False):
def command_print( def command_print(
canvas : Canvas canvas: Canvas
): ):
""" """
Creates a print directive. Creates a print directive.
@ -369,7 +469,8 @@ def command_print(
payload.extend(DirectiveBuilder.start()) payload.extend(DirectiveBuilder.start())
payload.extend(DirectiveBuilder.print( payload.extend(DirectiveBuilder.print(
canvas.get_image(), canvas.get_image(),
canvas.get_width(), canvas.width,
canvas.height,
bits_per_pixel = 1, bits_per_pixel = 1,
alignment = 2 alignment = 2
)) ))
@ -380,7 +481,7 @@ def command_print(
def command_casette( def command_casette(
media_type : int media_type: int
): ):
""" """
Creates a casette directive. Creates a casette directive.

View File

@ -1,6 +1,6 @@
[project] [project]
name = "dymo-bluetooth" name = "dymo-bluetooth"
version = "0.0.1" version = "0.0.2"
authors = [{ name = "ysfchn" }] authors = [{ name = "ysfchn" }]
license.file = "LICENSE" license.file = "LICENSE"
readme = "README.md" readme = "README.md"