Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions src/labelfab/render/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,29 @@ def draw(self, img: Image.Image, d: ImageDraw.ImageDraw, box: Rect) -> None:
# --------------------------------------------------------------------------- #


def _wrap(text: str, font: ImageFont.FreeTypeFont, width: int) -> list[str]:
"""Greedy wrap, breaking over-long words rather than overflowing."""
def _wrap(
text: str, font: ImageFont.FreeTypeFont, width: int, *, break_words: bool = True
) -> list[str] | None:
"""Greedy wrap, breaking over-long words rather than overflowing.

With ``break_words=False`` a word wider than the line fails the wrap (``None``)
instead, so the caller can try a smaller size first. Splitting a code such as
``INV-SI1`` into ``INV-SI`` and ``1`` makes it read as two different things.
"""
lines: list[str] = []
for paragraph in text.split("\n"):
current = ""
for word in paragraph.split():
trial = f"{current} {word}".strip()
if not current or font.getlength(trial) <= width:
# The first word of a paragraph is measured too. It used to be accepted
# unmeasured, so a one-word title grew until it hit max_pt and ran off
# the side of the label.
if font.getlength(trial) <= width:
current = trial
continue
if font.getlength(word) > width:
if not break_words:
return None
# A single unbreakable token; split it at the pixel boundary.
if current:
lines.append(current)
Expand Down Expand Up @@ -242,7 +254,9 @@ class TextDraw:
spec: TextElement
flex: float = 1.0

def _fit(self, width: int, height: int) -> tuple[ImageFont.FreeTypeFont, list[str]]:
def _search(
self, width: int, height: int, *, break_words: bool
) -> tuple[ImageFont.FreeTypeFont, list[str]] | None:
"""Binary search the largest point size whose wrap fits the box."""
lo, hi = self.spec.min_pt, self.spec.max_pt
best: tuple[ImageFont.FreeTypeFont, list[str]] | None = None
Expand All @@ -251,11 +265,23 @@ def _fit(self, width: int, height: int) -> tuple[ImageFont.FreeTypeFont, list[st
break
mid = (lo + hi) / 2
font = load_font(bold=self.spec.bold, condensed=self.spec.condensed, size_px=pt_to_px(mid))
lines = _wrap(self.spec.value, font, width)
if len(lines) <= self.spec.max_lines and len(lines) * _line_height(font) <= height:
lines = _wrap(self.spec.value, font, width, break_words=break_words)
if (
lines is not None
and len(lines) <= self.spec.max_lines
and len(lines) * _line_height(font) <= height
):
best, lo = (font, lines), mid
else:
hi = mid
return best

def _fit(self, width: int, height: int) -> tuple[ImageFont.FreeTypeFont, list[str]]:
# Whole words first. A tall box would otherwise let a bigger size that splits
# a word beat a smaller one that keeps it intact.
best = self._search(width, height, break_words=False) or self._search(
width, height, break_words=True
)
if best is not None:
return best

Expand Down Expand Up @@ -289,8 +315,10 @@ def measure(self, avail_w: int, avail_h: int) -> tuple[int, int]:
pt = min(self.spec.max_pt, max(self.spec.min_pt, per_line * 72.0 / 203.0 * 0.95))
font = load_font(bold=self.spec.bold, condensed=self.spec.condensed, size_px=pt_to_px(pt))
single = int(font.getlength(self.spec.value.replace("\n", " ")))
# Approximate a balanced wrap across the allowed number of lines.
natural_w = min(avail_w, max(1, -(-single // self.spec.max_lines)))
# Approximate a balanced wrap across the allowed number of lines, but never
# narrower than the longest word, which cannot wrap.
widest = max((int(font.getlength(w)) + 1 for w in self.spec.value.split()), default=1)
natural_w = min(avail_w, max(1, -(-single // self.spec.max_lines), widest))
lines = _wrap(self.spec.value, font, natural_w)
return natural_w, min(avail_h, len(lines) * _line_height(font))

Expand Down
42 changes: 42 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,48 @@ def test_very_long_text_is_truncated_not_overflowed():
assert img.size == (mm_to_px(20), mm_to_px(15))


def test_a_one_word_title_is_sized_to_the_box_not_past_it():
"""The first word of a paragraph used to skip the width check, so a one-word
title grew to max_pt and ran off the label: "INV-SI1" printed as "INV-SI"."""
from labelfab.render.elements import TextDraw

td = TextDraw(spec=TextElement(value="INV-SI1", max_lines=2, bold=True, max_pt=16))
font, lines = td._fit(120, 60)
assert all(font.getlength(line) <= 120 for line in lines)


def test_a_word_is_kept_whole_when_a_smaller_size_fits():
"""With room for two lines, "INV-S" / "I1" at a bigger size also passes the fit.
A code split in two reads as two codes, so the smaller whole word wins."""
from labelfab.render.elements import TextDraw

td = TextDraw(spec=TextElement(value="INV-SI1", max_lines=2, bold=True, max_pt=16))
font, lines = td._fit(120, 120)
assert lines == ["INV-SI1"]
assert font.getlength(lines[0]) <= 120


def test_a_word_too_wide_at_any_size_is_still_split():
"""Whole words are preferred, not required. A token that cannot fit at min_pt is
split across lines rather than truncated to nothing."""
from labelfab.render.elements import TextDraw

td = TextDraw(spec=TextElement(value="X" * 20, max_lines=2, min_pt=5, max_pt=6))
font, lines = td._fit(120, 60)
assert len(lines) == 2
assert all(font.getlength(line) <= 120 for line in lines)


def test_a_one_word_title_leaves_the_right_edge_clear():
img = render_label(
LabelSpec(
preset="part", vars={"code": "PA12", "title": "INV-PA12", "sub": "Widget"}, length_mm=30
),
TAPE_12,
)
assert all(img.getpixel((img.width - 1, y)) >= 128 for y in range(img.height))


def test_text_stays_inside_the_canvas():
img = render_label(
LabelSpec(elements=[TextElement(value="ABCDEFGH", max_lines=1)], length_mm=25), TAPE_15
Expand Down
Loading