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

This commit is contained in:
Yusuf Cihan
2025-04-17 17:53:26 +03:00
parent 3e47b0116e
commit fe3cf155e3
5 changed files with 379 additions and 159 deletions

View File

@ -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.