Refactor Canvas with new revert(), text(), fill() methods
This commit is contained in:
parent
3e47b0116e
commit
fe3cf155e3
39
README.md
39
README.md
@ -29,7 +29,7 @@ This project depends on [`bleak`](https://pypi.org/project/bleak/) for cross-pla
|
||||
## 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;
|
||||
@ -41,7 +41,7 @@ Additionally, if `python-barcode` is installed (which is optional), it can be al
|
||||
|
||||
## 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
|
||||
@ -63,18 +63,26 @@ async def main():
|
||||
# Images needs to be stretched by at least 2 times like
|
||||
# how its mobile app does, otherwise printer will render
|
||||
# the labels too narrow.
|
||||
canvas = canvas.stretch_image(2)
|
||||
canvas = canvas.stretch(2)
|
||||
|
||||
# Get a list of 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.
|
||||
await printers[0].print(canvas)
|
||||
|
||||
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
|
||||
|
||||
This program is licensed under [MIT License](./LICENSE).
|
||||
@ -88,7 +96,7 @@ 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:
|
||||
|
||||
| UUID | Used for |
|
||||
|:------:|:-------:|
|
||||
|:------:|:--------:|
|
||||
| `be3dd650-2b3d-42f1-99c1-f0f749dd0678` | Service |
|
||||
| `be3dd651-2b3d-42f1-99c1-f0f749dd0678` | [Print request (write -> printer)](#print-request) |
|
||||
| `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
|
||||
|
||||
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
|
||||
@ -133,7 +141,7 @@ START[6] = DIRECTIVE[2] + 9A 02 00 00
|
||||
|
||||
#### `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.
|
||||
|
||||
@ -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.
|
||||
|
||||
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]
|
||||
```
|
||||
|
||||
## 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)
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
from argparse import ArgumentParser, BooleanOptionalAction
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from typing import Literal, Union, cast
|
||||
from dymo_bluetooth.bluetooth import discover_printers, create_image
|
||||
import sys
|
||||
import asyncio
|
||||
@ -32,18 +32,36 @@ async def print_image(
|
||||
input_file: Path,
|
||||
max_timeout: 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 = canvas.stretch_image(factor = stretch_factor)
|
||||
printers = await discover_printers(max_timeout)
|
||||
if stretch_factor != 1:
|
||||
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:
|
||||
print("Couldn't find any printers in given timeout!")
|
||||
print("Couldn't find any printers, is the printer online?", file = sys.stderr)
|
||||
exit(1)
|
||||
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.print(canvas)
|
||||
print("Printing...", file = sys.stderr)
|
||||
result = await printer.print(canvas)
|
||||
print(f"Result: {result.name} ({result.value})", file = sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
@ -51,28 +69,107 @@ def main():
|
||||
args = ArgumentParser(
|
||||
prog = f"python -m {module_name}",
|
||||
description = (
|
||||
"Print monochrome labels with DYMO LetraTag LT-200B label printer over "
|
||||
"Bluetooth."
|
||||
"Print monochrome labels with DYMO LetraTag LT-200B label printer over 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("--timeout", default = 5, type = int, help = "Maximum timeout to search for printers.") # noqa: E501
|
||||
args.add_argument("--dither", default = True, action = BooleanOptionalAction,
|
||||
help = "Use or don't use dithering when converting it to monochrome image."
|
||||
args.add_argument(
|
||||
"image",
|
||||
help = (
|
||||
"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(
|
||||
"--ensure-mac",
|
||||
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."
|
||||
)
|
||||
args.add_argument("--factor", default = 2, type = int,
|
||||
help = "Stretch the image N times. Default is 2, otherwise printer" + \
|
||||
"will print images too thin."
|
||||
)
|
||||
parsed = args.parse_args()
|
||||
input_file = cast(Path, parsed.image).absolute()
|
||||
max_timeout = cast(int, parsed.timeout)
|
||||
stretch_factor = cast(int, parsed.factor)
|
||||
stretch_factor = cast(int, parsed.stretch)
|
||||
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():
|
||||
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)
|
||||
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__":
|
||||
|
@ -23,6 +23,7 @@
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from PIL import Image, ImageChops
|
||||
from bleak import BleakScanner, BleakClient
|
||||
from typing import List, TYPE_CHECKING
|
||||
@ -43,14 +44,12 @@ UNKNOWN_UUID = "be3dd653-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d")
|
||||
|
||||
def is_espressif(input_mac: str):
|
||||
"""
|
||||
Returns True if given MAC address is from a valid DYMO printer.
|
||||
The mac address blocks are owned by Espressif Inc.
|
||||
Returns True if given MAC address is from Espressif Inc.
|
||||
"""
|
||||
mac_blocks = [
|
||||
"58:CF:79",
|
||||
# Confirmed in pull request #2
|
||||
"DC:54:75",
|
||||
"34:85:18"
|
||||
"DC:54:75", # confirmed in #2
|
||||
"34:85:18", # confirmed in #4
|
||||
]
|
||||
check_mac = int(input_mac.replace(":", ""), base = 16)
|
||||
for mac in mac_blocks:
|
||||
@ -99,7 +98,7 @@ class Printer:
|
||||
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
|
||||
has found in the initial search, waits for scanning until the max timeout has been
|
||||
@ -112,11 +111,14 @@ async def discover_printers(max_timeout : int = 5) -> List[Printer]:
|
||||
# TODO: In some cases, advetisement data may be non-null, containing
|
||||
# additional metadata about printer state but it is not implemented yet.
|
||||
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 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))
|
||||
# Do we have any candidate printers? If so, return the found printers.
|
||||
# Otherwise, wait for the next scans until we found any.
|
||||
@ -147,14 +149,15 @@ def convert_image_to_canvas(
|
||||
output = output.crop(diff.getbbox(alpha_only = False))
|
||||
# Shrink the image from the center if it exceeds the print height,
|
||||
# or max printable width.
|
||||
if (output.height > Canvas.HEIGHT):
|
||||
start_y = int(output.height / 2) - int(Canvas.HEIGHT / 2)
|
||||
canvas_height = Canvas.BYTES_PER_LINE * 8
|
||||
if (output.height > canvas_height):
|
||||
start_y = int(output.height / 2) - int(canvas_height / 2)
|
||||
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.")
|
||||
if (output.width) > Canvas.MAX_WIDTH:
|
||||
if (output.width) > Canvas.MAX_LENGTH:
|
||||
raise ValueError("Image is too large, resizing not implemented.")
|
||||
# Convert image to pixel array.
|
||||
canvas = Canvas()
|
||||
|
@ -22,12 +22,9 @@
|
||||
|
||||
from enum import Enum
|
||||
from io import SEEK_END, BytesIO
|
||||
from typing import Union
|
||||
from typing import Literal, Sequence, Tuple
|
||||
import math
|
||||
|
||||
BytesLike = Union[bytes, bytearray]
|
||||
|
||||
|
||||
class DirectiveCommand(Enum):
|
||||
"""
|
||||
Directives that accepted by the printer.
|
||||
@ -75,7 +72,7 @@ class Result(Enum):
|
||||
FAILED_NO_CASETTE = 7
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data : BytesLike):
|
||||
def from_bytes(cls, data: Sequence[int]):
|
||||
if data[0] != 27:
|
||||
raise ValueError("Not a valid result value; 1st byte must be 0x1b (27)")
|
||||
if chr(data[1]) != "R":
|
||||
@ -93,149 +90,142 @@ class Result(Enum):
|
||||
|
||||
class Canvas:
|
||||
"""
|
||||
An implementation of 1-bit monochrome little-endian encoded 32 pixel height
|
||||
images for printing them to the label.
|
||||
An implementation of 1-bit monochrome horizontal images, sequentially
|
||||
ordered and stored in big-endian for each pixel.
|
||||
|
||||
1 byte equals 8 bits, and each bit specifies if pixel should be black (= 1)
|
||||
or white (= 0). The generated image will contain a multiple of 4 bytes, equaling
|
||||
the label height in pixels (4 * 8 = 32 pixels).
|
||||
1 byte equals 8 bits, and each bit specifies if pixel should be filled
|
||||
(= 1) or not (= 0).
|
||||
"""
|
||||
|
||||
__slots__ = ("buffer", )
|
||||
|
||||
HEIGHT = 32
|
||||
MAX_WIDTH = 8186
|
||||
# Each line takes 4 bytes, equals to 32 pixels (1 byte = 8 bits).
|
||||
BYTES_PER_LINE = 4
|
||||
|
||||
# Maximum count of bytes that the image can extend into the fixed direction.
|
||||
MAX_LENGTH = 1024
|
||||
|
||||
def __init__(self) -> None:
|
||||
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.
|
||||
Returns True if pixel is black, otherwise False.
|
||||
Returns True if pixel is filled (= black), otherwise False.
|
||||
"""
|
||||
if y >= Canvas.HEIGHT:
|
||||
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.")
|
||||
self._raise_if_out_bounds(x, y)
|
||||
|
||||
# Get the byte containing the pixel value of given coordinates.
|
||||
x_offset = math.ceil((x * Canvas.HEIGHT) / 8)
|
||||
y_offset = ((self.HEIGHT) // 8) - 1 - math.floor(y / 8)
|
||||
x_offset = x * Canvas.BYTES_PER_LINE
|
||||
y_offset = Canvas.BYTES_PER_LINE - 1 - math.floor(y / 8)
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
|
||||
# Check if there is a bit value in given line.
|
||||
read = self.buffer.read(1)
|
||||
value = int.from_bytes(read or b"\x00", "little")
|
||||
value = (self.buffer.read(1) or b"\x00")[0]
|
||||
is_black = bool(value & (1 << (7 - (y % 8))))
|
||||
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.
|
||||
Setting color to True will paint a black color.
|
||||
"""
|
||||
if y >= Canvas.HEIGHT:
|
||||
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.")
|
||||
self._raise_if_out_bounds(x, y)
|
||||
|
||||
# Get the byte containing the pixel value of given coordinates.
|
||||
x_offset = math.ceil((x * Canvas.HEIGHT) / 8)
|
||||
y_offset = ((self.HEIGHT) // 8) - 1 - math.floor(y / 8)
|
||||
x_offset = x * Canvas.BYTES_PER_LINE
|
||||
y_offset = Canvas.BYTES_PER_LINE - 1 - math.floor(y / 8)
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
|
||||
# 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.
|
||||
read = self.buffer.read(1)
|
||||
value = int.from_bytes(read or b"\x00", "little")
|
||||
curr = self.buffer.read(1)
|
||||
value = (curr or b"\x00")[0]
|
||||
if color:
|
||||
value = value | (1 << (7 - (y % 8)))
|
||||
else:
|
||||
value = value & ~(1 << (7 - (y % 8)))
|
||||
|
||||
# Change the current part of the line with modified one.
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
# Change the current byte with modified one, if not exists, append a new one.
|
||||
self.buffer.seek(0 if not curr else -1, 1)
|
||||
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
|
||||
containing the stretched image. Factor 1 results in the same image.
|
||||
Stretches image to the non-fixed direction in N times, and returns a new
|
||||
Canvas containing the stretched image. Factor 1 results in the same image.
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError("Stretch factor must be at least 1!")
|
||||
canvas = Canvas()
|
||||
for x in range(self.get_width()):
|
||||
for y in range(Canvas.HEIGHT):
|
||||
for x in range(self.width):
|
||||
for y in range(self.height):
|
||||
pixel = self.get_pixel(x, y)
|
||||
start_x = (x * factor)
|
||||
for i in range(factor):
|
||||
canvas.set_pixel(start_x + i, y, pixel)
|
||||
return canvas
|
||||
|
||||
def pad_image(self, width : int):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
def _get_byte_size(self):
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
cur = self.buffer.tell()
|
||||
return math.ceil(cur / (self.HEIGHT // 8))
|
||||
return self.buffer.tell()
|
||||
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
self.buffer.seek(0)
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
byte_count = self.buffer.tell()
|
||||
size = self._get_byte_size()
|
||||
self.buffer.seek(0)
|
||||
self.buffer.truncate(0)
|
||||
self.buffer.seek(byte_count)
|
||||
self.buffer.seek(size)
|
||||
self.buffer.write(b"\x00")
|
||||
|
||||
def copy(self):
|
||||
def copy(self) -> "Canvas":
|
||||
"""
|
||||
Creates a copy of this canvas.
|
||||
"""
|
||||
@ -251,16 +241,125 @@ class Canvas:
|
||||
self.buffer.seek(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:
|
||||
if not isinstance(value, Canvas):
|
||||
return False
|
||||
return value.get_image() == self.get_image()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
byte_size = self.buffer.tell()
|
||||
image_size = "x".join(map(str, self.get_size()))
|
||||
return f"<{self.__class__.__name__} image={image_size} bytes={byte_size}>"
|
||||
w, h = self.size
|
||||
return f"<{self.__class__.__name__} size={w}x{h} length={self.__len__()}>"
|
||||
|
||||
|
||||
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:
|
||||
@ -294,14 +393,15 @@ class DirectiveBuilder:
|
||||
|
||||
@staticmethod
|
||||
def print(
|
||||
data : BytesLike,
|
||||
data: Sequence[int],
|
||||
image_width: int,
|
||||
image_height: int,
|
||||
bits_per_pixel: int,
|
||||
alignment: int
|
||||
):
|
||||
size = \
|
||||
image_width.to_bytes(4, "little") + \
|
||||
Canvas.HEIGHT.to_bytes(4, "little")
|
||||
image_height.to_bytes(4, "little")
|
||||
return bytes([
|
||||
*DirectiveCommand.PRINT_DATA.to_bytes(),
|
||||
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.
|
||||
|
||||
@ -369,7 +469,8 @@ def command_print(
|
||||
payload.extend(DirectiveBuilder.start())
|
||||
payload.extend(DirectiveBuilder.print(
|
||||
canvas.get_image(),
|
||||
canvas.get_width(),
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
bits_per_pixel = 1,
|
||||
alignment = 2
|
||||
))
|
||||
|
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dymo-bluetooth"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
authors = [{ name = "ysfchn" }]
|
||||
license.file = "LICENSE"
|
||||
readme = "README.md"
|
||||
|
Loading…
x
Reference in New Issue
Block a user