diff --git a/src/labelfab/render/elements.py b/src/labelfab/render/elements.py index a9867ea..929b5fc 100644 --- a/src/labelfab/render/elements.py +++ b/src/labelfab/render/elements.py @@ -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) @@ -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 @@ -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 @@ -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)) diff --git a/tests/test_render.py b/tests/test_render.py index 86c346d..2a68c54 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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