Refactor Canvas with new revert(), text(), fill() methods
This commit is contained in:
@@ -22,28 +22,46 @@
|
||||
|
||||
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
|
||||
import os
|
||||
|
||||
async def print_image(
|
||||
input_file : Path,
|
||||
max_timeout : int,
|
||||
stretch_factor : int,
|
||||
use_dither : bool
|
||||
input_file: Path,
|
||||
max_timeout: int,
|
||||
stretch_factor: int,
|
||||
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("--factor", default = 2, type = int,
|
||||
help = "Stretch the image N times. Default is 2, otherwise printer" + \
|
||||
"will print images too thin."
|
||||
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."
|
||||
)
|
||||
)
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
|
||||
def is_espressif(input_mac : str):
|
||||
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:
|
||||
@@ -62,7 +61,7 @@ def is_espressif(input_mac : str):
|
||||
|
||||
|
||||
class Printer:
|
||||
def __init__(self, impl : "BLEDevice") -> None:
|
||||
def __init__(self, impl: "BLEDevice") -> None:
|
||||
self._impl = impl
|
||||
self._client = BleakClient(self._impl)
|
||||
|
||||
@@ -76,16 +75,16 @@ class Printer:
|
||||
return
|
||||
await self._client.disconnect()
|
||||
|
||||
async def print(self, canvas : Canvas):
|
||||
async def print(self, canvas: Canvas):
|
||||
if not self._client.is_connected:
|
||||
raise Exception("Printer is not connected!")
|
||||
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
|
||||
future : asyncio.Future[Result] = asyncio.Future()
|
||||
should_discard : bool = False
|
||||
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
|
||||
future: asyncio.Future[Result] = asyncio.Future()
|
||||
should_discard: bool = False
|
||||
# Printer sends two messages, first is the PRINTING, and second one is the
|
||||
# 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
|
||||
result = Result.from_bytes(data)
|
||||
if (not should_discard) and (result.value in [0, 1]):
|
||||
@@ -99,24 +98,27 @@ 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
|
||||
reached.
|
||||
"""
|
||||
printers : List[Printer] = []
|
||||
printers: List[Printer] = []
|
||||
waited_total = 0
|
||||
async with BleakScanner(service_uuids = [SERVICE_UUID]) as scanner:
|
||||
while True:
|
||||
# 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.
|
||||
@@ -130,9 +132,9 @@ async def discover_printers(max_timeout : int = 5) -> List[Printer]:
|
||||
|
||||
|
||||
def convert_image_to_canvas(
|
||||
image : Image.Image,
|
||||
dither : bool = True,
|
||||
trim : bool = False
|
||||
image: Image.Image,
|
||||
dither: bool = True,
|
||||
trim: bool = False
|
||||
):
|
||||
"""
|
||||
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))
|
||||
# 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()
|
||||
@@ -165,7 +168,7 @@ def convert_image_to_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.
|
||||
"""
|
||||
@@ -177,7 +180,7 @@ def create_image(path : Path, dither : bool = True):
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
@@ -277,7 +376,7 @@ class DirectiveBuilder:
|
||||
return bytes([*DirectiveCommand.START.to_bytes(), 154, 2, 0, 0])
|
||||
|
||||
@staticmethod
|
||||
def media_type(value : int):
|
||||
def media_type(value: int):
|
||||
return bytes([*DirectiveCommand.MEDIA_TYPE.to_bytes(), value])
|
||||
|
||||
@staticmethod
|
||||
@@ -294,14 +393,15 @@ class DirectiveBuilder:
|
||||
|
||||
@staticmethod
|
||||
def print(
|
||||
data : BytesLike,
|
||||
image_width : int,
|
||||
bits_per_pixel : int,
|
||||
alignment : int
|
||||
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.
|
||||
|
||||
@@ -347,7 +447,7 @@ def create_payload(data : BytesLike, is_print : bool = False):
|
||||
# Split chunk in each 500 bytes.
|
||||
for index, step in enumerate(range(0, len(data), CHUNK_SIZE)):
|
||||
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
|
||||
# vendor app skips this index, so we do the same here.
|
||||
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(
|
||||
canvas : Canvas
|
||||
canvas: Canvas
|
||||
):
|
||||
"""
|
||||
Creates a print directive.
|
||||
@@ -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
|
||||
))
|
||||
@@ -380,7 +481,7 @@ def command_print(
|
||||
|
||||
|
||||
def command_casette(
|
||||
media_type : int
|
||||
media_type: int
|
||||
):
|
||||
"""
|
||||
Creates a casette directive.
|
||||
|
||||
Reference in New Issue
Block a user