init commit with repo structure and basic files
This commit is contained in:
parent
41ad34550b
commit
3865dcc402
13 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