init commit with repo structure and basic files
This commit is contained in:
parent
41ad34550b
commit
662e82f46e
14 changed files with 393 additions and 0 deletions
0
labeler/adapter/fastapi.py
Normal file
0
labeler/adapter/fastapi.py
Normal file
25
labeler/app/labeler.py
Normal file
25
labeler/app/labeler.py
Normal file
|
|
@ -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)
|
||||
123
labeler/domain/objects.py
Normal file
123
labeler/domain/objects.py
Normal file
|
|
@ -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
|
||||
125
labeler/infra/renderer.py
Normal file
125
labeler/infra/renderer.py
Normal file
|
|
@ -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
|
||||
15
labeler/interfaces.py
Normal file
15
labeler/interfaces.py
Normal file
|
|
@ -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
|
||||
1
tests/conftest.py
Normal file
1
tests/conftest.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from fixtures import * # noqa
|
||||
25
tests/fixtures.py
Normal file
25
tests/fixtures.py
Normal file
|
|
@ -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
|
||||
29
tests/labeler/domain/test_objects.py
Normal file
29
tests/labeler/domain/test_objects.py
Normal file
|
|
@ -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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
tests/labeler/infra/test_images/multiline_label_test.png
Normal file
BIN
tests/labeler/infra/test_images/multiline_label_test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
tests/labeler/infra/test_images/no_fixed_width.png
Normal file
BIN
tests/labeler/infra/test_images/no_fixed_width.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
tests/labeler/infra/test_images/simple_label_test.png
Normal file
BIN
tests/labeler/infra/test_images/simple_label_test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
50
tests/labeler/infra/test_renderer.py
Normal file
50
tests/labeler/infra/test_renderer.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue