From b8af0a6ede04b3b28ac0de5ab8e17e2f284c3034 Mon Sep 17 00:00:00 2001 From: AC Date: Wed, 14 Oct 2026 15:28:43 +0800 Subject: [PATCH] Expand image canvas to preserve wide barcode captions --- barcode/writer.py | 24 +++++- docs/changelog.rst | 5 ++ docs/writers.rst | 4 + tests/test_image_text_width.py | 142 +++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 tests/test_image_text_width.py diff --git a/barcode/writer.py b/barcode/writer.py index ca8a02d..7c4836b 100644 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -1,6 +1,7 @@ from __future__ import annotations import gzip +import math import os import xml.dom.minidom from typing import TYPE_CHECKING @@ -34,12 +35,13 @@ class Callbacks(TypedDict): from PIL import Image from PIL import ImageDraw from PIL import ImageFont + from PIL import ImageOps except ImportError: import logging log = logging.getLogger("pyBarcode") log.info("Pillow not found. Image output disabled") - Image = ImageDraw = ImageFont = None # type: ignore[assignment] + Image = ImageDraw = ImageFont = ImageOps = None # type: ignore[assignment] def mm2px(mm: float, dpi: int) -> float: @@ -466,6 +468,7 @@ def _init(self, code: list[str]) -> None: size = (int(mm2px(width, self.dpi)), int(mm2px(height, self.dpi))) self._image = Image.new(self.mode, size, self.background) self._draw = ImageDraw.Draw(self._image) + self._text_offset = 0 def _paint_module( self, @@ -485,6 +488,8 @@ def _paint_module( def _paint_text(self, xpos: float, ypos: float) -> None: assert ImageFont is not None + assert ImageOps is not None + assert ImageDraw is not None # check option to override self.text with self.human (barcode as # human readable data, can be used to print own formats) @@ -496,9 +501,24 @@ def _paint_text(self, xpos: float, ypos: float) -> None: font = ImageFont.truetype(self.font_path, font_size) for subtext in barcodetext.split("\n"): pos = ( - mm2px(xpos, self.dpi), + mm2px(xpos, self.dpi) + self._text_offset, mm2px(ypos, self.dpi), ) + left, _, right, _ = self._draw.textbbox( + pos, subtext, font=font, anchor="md" + ) + padding_left = math.ceil(max(0, -left)) + padding_right = math.ceil(max(0, right - self._image.width)) + if padding_left or padding_right: + # Shift modules and earlier text together when adding left space. + self._image = ImageOps.expand( + self._image, + border=(padding_left, 0, padding_right, 0), + fill=self.background, + ) + self._draw = ImageDraw.Draw(self._image) + self._text_offset += padding_left + pos = (pos[0] + padding_left, pos[1]) self._draw.text( pos, subtext, font=font, fill=self.foreground, anchor="md" ) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a15b52..efad6ab 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,11 @@ Changelog --------- +Unreleased +~~~~~~~~~~ +* Prevent ImageWriter from clipping wide human-readable text at the left and + right edges. + v0.17.0 ~~~~~~~ * Add support for Python 3.13, 3.14 and 3.15. diff --git a/docs/writers.rst b/docs/writers.rst index c5399c1..679ae2d 100644 --- a/docs/writers.rst +++ b/docs/writers.rst @@ -95,6 +95,10 @@ In addition to the common writer options you can give the following special opti used for all mm to px calculations. Defaults to ``300`` +When text extends beyond the barcode width, ``ImageWriter`` expands the image +horizontally to include it. Module widths and heights are preserved; the quiet +zones may grow to accommodate the text. + Custom writers -------------- diff --git a/tests/test_image_text_width.py b/tests/test_image_text_width.py new file mode 100644 index 0000000..4ae933f --- /dev/null +++ b/tests/test_image_text_width.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import math + +import pytest + +from barcode import Gs1_128 +from barcode.writer import ImageWriter +from barcode.writer import mm2px +from barcode.writer import pt2mm + +if ImageWriter is not None: + from PIL import Image + from PIL import ImageChops + from PIL import ImageDraw + from PIL import ImageFont + + @pytest.mark.parametrize("dpi", [150, 300]) + @pytest.mark.parametrize("center_text", [True, False]) + @pytest.mark.parametrize( + "caption", ["", "A much longer caption\nAnd a second line"] + ) + def test_image_contains_complete_caption( + dpi: int, center_text: bool, caption: str + ) -> None: + assert ImageWriter is not None + writer = ImageWriter(dpi=dpi) + barcode = Gs1_128("00376401856400470087", writer=writer) + image = barcode.render({"center_text": center_text}, text=caption or None) + assert isinstance(image, Image.Image) + font = ImageFont.truetype( + writer.font_path, int(mm2px(pt2mm(writer.font_size), dpi)) + ) + text = caption or barcode.get_fullcode() + width, _ = writer.calculate_size(len(barcode.build()[0]), 1) + xpos = width / 2 if center_text else writer.quiet_zone + ypos = writer.margin_top + writer.module_height + writer.text_distance + expected = Image.new("RGB", (image.width + 2048, image.height), "white") + draw = ImageDraw.Draw(expected) + for line in text.split("\n"): + draw.text( + (mm2px(xpos, dpi) + 1024, mm2px(ypos, dpi)), + line, + font=font, + fill="black", + anchor="md", + ) + ypos += pt2mm(writer.font_size) / 2 + writer.text_line_distance + text_top = math.ceil(mm2px(writer.margin_top + writer.module_height, dpi)) + 1 + actual = image.crop((0, text_top, image.width, image.height)) + reference = expected.crop((0, text_top, expected.width, expected.height)) + actual_box = ImageChops.difference( + actual, Image.new("RGB", actual.size, "white") + ).getbbox() + reference_box = ImageChops.difference( + reference, Image.new("RGB", reference.size, "white") + ).getbbox() + assert actual_box is not None + assert reference_box is not None + assert actual.crop(actual_box).size == reference.crop(reference_box).size + assert ( + actual.crop(actual_box).tobytes() == reference.crop(reference_box).tobytes() + ) + + def test_expanding_caption_preserves_barcode_and_resets_between_images() -> None: + assert ImageWriter is not None + writer = ImageWriter() + barcode = Gs1_128("00376401856400470087", writer=writer) + plain = barcode.render({"write_text": False}) + labelled = barcode.render() + assert isinstance(plain, Image.Image) + assert isinstance(labelled, Image.Image) + assert labelled.width > plain.width + top = math.ceil(mm2px(writer.margin_top + writer.module_height, writer.dpi)) + 1 + plain_bars = plain.crop((0, 0, plain.width, top)) + labelled_bars = labelled.crop((0, 0, labelled.width, top)) + plain_box = ImageChops.difference( + plain_bars, Image.new("RGB", plain_bars.size, "white") + ).getbbox() + labelled_box = ImageChops.difference( + labelled_bars, Image.new("RGB", labelled_bars.size, "white") + ).getbbox() + assert plain_box is not None + assert labelled_box is not None + assert plain_bars.crop(plain_box).size == labelled_bars.crop(labelled_box).size + assert ( + plain_bars.crop(plain_box).tobytes() + == labelled_bars.crop(labelled_box).tobytes() + ) + plain_again = barcode.render({"write_text": False}) + labelled_again = barcode.render() + assert isinstance(plain_again, Image.Image) + assert isinstance(labelled_again, Image.Image) + assert plain_again.tobytes() == plain.tobytes() + assert labelled_again.tobytes() == labelled.tobytes() + + def test_human_caption_expansion_preserves_transparent_background() -> None: + assert ImageWriter is not None + writer = ImageWriter(mode="RGBA") + barcode = Gs1_128("00376401856400470087", writer=writer) + caption = "A long custom human-readable caption" + options = { + "background": (0, 0, 0, 0), + "human": caption, + } + image = barcode.render(options) + assert isinstance(image, Image.Image) + assert image.mode == "RGBA" + assert image.getpixel((0, 0)) == (0, 0, 0, 0) + assert image.getpixel((image.width - 1, 0)) == (0, 0, 0, 0) + reference = Gs1_128( + "00376401856400470087", writer=ImageWriter(mode="RGBA") + ).render({"background": (0, 0, 0, 0)}, text=caption) + assert isinstance(reference, Image.Image) + assert image.size == reference.size + assert image.tobytes() == reference.tobytes() + + def test_guarded_text_blocks_keep_their_relative_positions() -> None: + from barcode import EAN13 + + assert ImageWriter is not None + image = EAN13("590123412345", writer=ImageWriter(), guardbar=True).render( + {"font_size": 36} + ) + # A wider quiet zone is an independent reference where no block is clipped. + reference = EAN13("590123412345", writer=ImageWriter(), guardbar=True).render( + {"font_size": 36, "quiet_zone": 6.5 + 500 * 25.4 / 300} + ) + assert isinstance(image, Image.Image) + assert isinstance(reference, Image.Image) + actual_box = ImageChops.difference( + image, Image.new("RGB", image.size, "white") + ).getbbox() + reference_box = ImageChops.difference( + reference, Image.new("RGB", reference.size, "white") + ).getbbox() + assert actual_box is not None + assert reference_box is not None + assert image.crop(actual_box).size == reference.crop(reference_box).size + assert ( + image.crop(actual_box).tobytes() == reference.crop(reference_box).tobytes() + )