Add Pablo: the friendly label printer helper #5

Merged
marsien merged 15 commits from add-pablo into master 2026-04-16 14:02:48 +00:00
13 changed files with 393 additions and 0 deletions
Showing only changes of commit 3865dcc402 - Show all commits

View file

25
labeler/app/labeler.py Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
from fixtures import * # noqa

25
tests/fixtures.py Normal file
View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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