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