diff --git a/labeler/adapter/fastapi.py b/labeler/adapter/fastapi.py new file mode 100644 index 0000000..e69de29 diff --git a/labeler/app/labeler.py b/labeler/app/labeler.py new file mode 100644 index 0000000..a4ecc72 --- /dev/null +++ b/labeler/app/labeler.py @@ -0,0 +1,25 @@ +from labeler.domain.objects import Label, LabelRequest, LabelDefinition +from labeler.interfaces import Renderer, Printer + + +class Application: + def __init__(self, renderer: Renderer, printer: Printer): + self.renderer = renderer + self.printer = printer + + def render_preview(self, label_request: LabelRequest): + media = self.printer.get_installed_media() + + if label_request.length is not None: + label_length = label_request.length - 2 * media.minimal_margin_horizontal + else: + label_length = media.printable_length + + label_definition = LabelDefinition( + text=label_request.text, + length=label_length, + width=media.printable_width, + dpi=media.dpi, + ) + + self.renderer.render_label(label_definition) diff --git a/labeler/domain/objects.py b/labeler/domain/objects.py new file mode 100644 index 0000000..950efba --- /dev/null +++ b/labeler/domain/objects.py @@ -0,0 +1,123 @@ +import io +from math import inf + +from pydantic import BaseModel, Field + + +class Image(BaseModel): + bytes: bytes + width: int + height: int + + @classmethod + def from_pil(cls, pil_image): + buffer = io.BytesIO() + pil_image.save(buffer, format="PNG") + buffer.seek(0) + + return cls( + bytes=buffer.read(), + width=pil_image.width, + height=pil_image.height, + ) + + +class Dimension(BaseModel): + mm: float + __EPSILON = 0.0001 + + @classmethod + def from_inch(cls, inch: float) -> "Dimension": + return cls(mm=inch * 25.4) + + @classmethod + def from_points(cls, points: float, dpi: int) -> "Dimension": + return cls.from_inch(points / dpi) + + @property + def inch(self) -> float: + return self.mm / 25.4 + + def in_pixels(self, dpi: int) -> int: + return int(self.inch * dpi) + + def __ensure_same_type(self, other): + if type(other) != Dimension: + raise TypeError(f"Cannot use {other} to {self}") + + def __add__(self, other): + self.__ensure_same_type(other) + return Dimension(mm=self.mm + other.mm) + + def __sub__(self, other): + self.__ensure_same_type(other) + return Dimension(mm=self.mm - other.mm) + + def __mul__(self, other): + if type(other) not in (int, float): + raise TypeError(f"Cannot multiply {self} by {other}") + return Dimension(mm=self.mm * other) + + def __rmul__(self, other): + return self.__mul__(other) + + def __truediv__(self, other): + if type(other) not in (int, float): + raise TypeError(f"Cannot divide {self} by {other}") + + return Dimension(mm=self.mm / other) + + def __eq__(self, other): + self.__ensure_same_type(other) + if self.mm == inf and other.mm == inf: + return True + return abs(self.mm - other.mm) < self.__EPSILON + + def __lt__(self, other): + self.__ensure_same_type(other) + return self.mm < other.mm + + def __gt__(self, other): + self.__ensure_same_type(other) + return self.mm > other.mm + + +class LabelRequest(BaseModel): + text: str + length: Dimension | None + + +class LabelDefinition(BaseModel): + text: str + length: Dimension | None = None + width: Dimension + dpi: int + + @property + def pixel_width(self): + return self.width.in_pixels(self.dpi) + + @property + def pixel_length(self): + return self.length.in_pixels(self.dpi) + + +class MediaDefinition(BaseModel): + width: Dimension + length: Dimension + minimal_margin_vertical: Dimension + minimal_margin_horizontal: Dimension + dpi: int + + @property + def printable_width(self) -> Dimension: + return self.width - 2 * self.minimal_margin_horizontal + + @property + def printable_length(self) -> Dimension: + return self.length - 2 * self.minimal_margin_vertical + + +class Label(BaseModel): + dpi: str + image: Image diff --git a/labeler/infra/renderer.py b/labeler/infra/renderer.py new file mode 100644 index 0000000..59a3bcc --- /dev/null +++ b/labeler/infra/renderer.py @@ -0,0 +1,125 @@ +import textwrap +from string import ascii_letters + +from PIL import ImageFont, ImageDraw, Image as PILImage + +from labeler.domain.objects import Image, LabelDefinition +from labeler.interfaces import Renderer + + +class PILRenderer(Renderer): + def __init__(self): + self.font_path = "/Library/Fonts/Arial.ttf" + + def render_label(self, label_definition: LabelDefinition) -> Image: + if label_definition.length is None: + pil_image = self.__render_no_fixed_lenth(label_definition) + else: + pil_image = self.__render_fixed_length(label_definition) + + return Image.from_pil(pil_image) + + def __render_fixed_length(self, label_definition: LabelDefinition): + width = label_definition.pixel_width + length = label_definition.pixel_length + font, text = self.__get_font( + label_definition.text, + width, + length, + ) + im = PILImage.new("1", (length, width), 1) + draw = ImageDraw.Draw(im) + draw.text( + (length / 2, width / 2), + text, + font=font, + fill=0, + anchor="mm", + align="center", + ) + return im + + def __render_no_fixed_lenth(self, label_definition: LabelDefinition): + lines_to_print = label_definition.text.count("\n") + 1 + text = "\n".join([line.strip() for line in label_definition.text.split("\n")]) + + text_height = label_definition.pixel_width // lines_to_print + + while text_height > 0: + font = ImageFont.truetype( + "/Library/Fonts/Arial.ttf", + text_height, + ) + if lines_to_print > 1: + occupied_height = font.getsize_multiline(text)[1] + else: + occupied_height = font.getsize(text)[1] + if occupied_height <= label_definition.pixel_width: + break + text_height -= 1 + + sizes = [font.getsize(line) for line in text.split("\n")] + + length = max(length for length, height in sizes) + + im = PILImage.new("1", (length, label_definition.pixel_width), 1) + draw = ImageDraw.Draw(im) + draw.text( + (length / 2, label_definition.pixel_width / 2), + text, + font=font, + fill=0, + anchor="mm", + align="center", + ) + return im + + def __get_font(self, text: str, max_width: int, max_length: int): + font_size = max_width + step = max_width // 2 + last_good = None + last_corrected = None + while step > 1: + fits, corrected = self.__will_font_fit( + text, self.font_path, font_size, max_width, max_length + ) + if fits: + last_good = font_size + last_corrected = corrected + font_size += step + else: + font_size -= step + step //= 2 + + return ImageFont.truetype(self.font_path, last_good), last_corrected + + def __will_font_fit( + self, text: str, font_path: str, font_size: int, max_width: int, max_length: int + ): + font = ImageFont.truetype(font_path, font_size) + if "\n" in text: + text_width, text_height = font.getsize_multiline(text) + else: + text_width, text_height = font.getsize(text) + + if text_height > max_width: + return False, None + + if text_width <= max_length: + # Now we know that the text fits. We can stop trying + return True, text + + avg_char_width = sum(font.getsize(char)[0] for char in ascii_letters) / len( + ascii_letters + ) + charachters_per_line = max_length // avg_char_width + if charachters_per_line < max(len(line) for line in text.split(" ")): + return False, None + + wrapped = textwrap.fill(text, charachters_per_line) + wrapped_width, wrapped_height = font.getsize_multiline(wrapped) + if wrapped_height <= max_width and wrapped_width <= max_length: + # Now we know that the text fits. We can stop trying + return True, wrapped + + return False, None diff --git a/labeler/interfaces.py b/labeler/interfaces.py new file mode 100644 index 0000000..fdbc9d5 --- /dev/null +++ b/labeler/interfaces.py @@ -0,0 +1,15 @@ +import abc + +from labeler.domain.objects import LabelDefinition, Image, MediaDefinition + + +class Renderer(abc.ABC): + @abc.abstractmethod + def render_label(self, label_definition: LabelDefinition) -> Image: + pass + + +class Printer(abc.ABC): + @abc.abstractmethod + def get_installed_media(self) -> MediaDefinition: + pass diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..84fb7c4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +from fixtures import * # noqa diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..5167dfc --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,25 @@ +import os +from typing import Callable + +import pytest + + +@pytest.fixture +def current_dir(request) -> str: + return os.path.dirname(request.module.__file__) + + +@pytest.fixture +def get_test_image(current_dir) -> Callable[[str], bytes]: + def f(name): + return open(os.path.join(current_dir, "test_images", name), "rb").read() + + return f + + +@pytest.fixture +def save_test_image(current_dir) -> Callable[[str, bytes], None]: + def f(name: str, data: bytes): + open(os.path.join(current_dir, "test_images", name), "wb").write(data) + + return f diff --git a/tests/labeler/domain/test_objects.py b/tests/labeler/domain/test_objects.py new file mode 100644 index 0000000..fc20f24 --- /dev/null +++ b/tests/labeler/domain/test_objects.py @@ -0,0 +1,29 @@ +import math + +from labeler.domain.objects import Dimension, MediaDefinition + + +def test_dimension(): + dimension = Dimension(mm=25.4) + + assert dimension.mm == 25.4 + assert dimension.inch == 1.0 + + assert dimension.in_pixels(dpi=300) == 300 + + assert dimension + Dimension(mm=10) == Dimension(mm=35.4) + assert dimension * 2 == Dimension(mm=50.8) + + assert dimension / 2 == Dimension(mm=12.7) + + +def test_infinite_media(): + media = MediaDefinition( + width=Dimension(mm=12), + length=Dimension(mm=math.inf), + minimal_margin_vertical=Dimension(mm=1), + minimal_margin_horizontal=Dimension(mm=2), + dpi=300, + ) + + assert media.printable_length == Dimension(mm=math.inf) diff --git a/tests/labeler/infra/test_images/multiline_label_no_fixed_width.png b/tests/labeler/infra/test_images/multiline_label_no_fixed_width.png new file mode 100644 index 0000000..60a35ed Binary files /dev/null and b/tests/labeler/infra/test_images/multiline_label_no_fixed_width.png differ diff --git a/tests/labeler/infra/test_images/multiline_label_test.png b/tests/labeler/infra/test_images/multiline_label_test.png new file mode 100644 index 0000000..d7a81e2 Binary files /dev/null and b/tests/labeler/infra/test_images/multiline_label_test.png differ diff --git a/tests/labeler/infra/test_images/no_fixed_width.png b/tests/labeler/infra/test_images/no_fixed_width.png new file mode 100644 index 0000000..abec891 Binary files /dev/null and b/tests/labeler/infra/test_images/no_fixed_width.png differ diff --git a/tests/labeler/infra/test_images/simple_label_test.png b/tests/labeler/infra/test_images/simple_label_test.png new file mode 100644 index 0000000..46caf96 Binary files /dev/null and b/tests/labeler/infra/test_images/simple_label_test.png differ diff --git a/tests/labeler/infra/test_renderer.py b/tests/labeler/infra/test_renderer.py new file mode 100644 index 0000000..f3597bc --- /dev/null +++ b/tests/labeler/infra/test_renderer.py @@ -0,0 +1,50 @@ +from labeler.domain.objects import LabelDefinition, Dimension +from labeler.infra.renderer import PILRenderer + + +def test_simple_label(get_test_image): + renderer = PILRenderer() + expected_label = get_test_image("simple_label_test.png") + + definition = LabelDefinition( + text="dolphin", length=Dimension(mm=40), width=Dimension(mm=10), dpi=600 + ) + + label = renderer.render_label(definition) + + assert label.bytes == expected_label + + +def test_multiline_label(get_test_image): + label_text = "dolphin\nis\nawesome" + expected_label = get_test_image("multiline_label_test.png") + + renderer = PILRenderer() + definition = LabelDefinition( + text=label_text, length=Dimension(mm=40), width=Dimension(mm=10), dpi=600 + ) + + label = renderer.render_label(definition) + assert label.bytes == expected_label + + +def test_simple_label_no_fixed_width(get_test_image, save_test_image): + renderer = PILRenderer() + expected_label = get_test_image("no_fixed_width.png") + + definition = LabelDefinition(text="dolphin", width=Dimension(mm=10), dpi=600) + + label = renderer.render_label(definition) + assert label.bytes == expected_label + + +def test_multiline_label_no_fixed_width(get_test_image, save_test_image): + renderer = PILRenderer() + expected_label = get_test_image("multiline_no_fixed_width.png") + + definition = LabelDefinition( + text="dolphin\nis\nawesome", width=Dimension(mm=10), dpi=600 + ) + + label = renderer.render_label(definition) + assert label.bytes == expected_label