From 425120af63770f7f96864926cdb5e0b406fa6170 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 18:45:54 +0100 Subject: [PATCH 01/13] Fix ISSN - don't throw out digits 11 and 12 when using EAN13 form as they are allowed by ISSN spec (see issn.org). --- .gitignore | 1 + barcode/isxn.py | 23 ++++++++++++++++++----- docs/changelog.rst | 6 ++++++ tests/test_checksums.py | 17 +++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 844a299..fdd5ae4 100755 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.kpf *.svg *.db +.coverage .idea/* barcode/__pycache* build/* diff --git a/barcode/isxn.py b/barcode/isxn.py index e74ea58..aa069f4 100755 --- a/barcode/isxn.py +++ b/barcode/isxn.py @@ -88,22 +88,33 @@ def __str__(self) -> str: class InternationalStandardSerialNumber(EuropeanArticleNumber13): """Initializes new ISSN barcode. This code is rendered as EAN-13 - by prefixing it with 977 and adding 00 between code and checksum. + by prefixing it with 977. + + The ISSN can be provided in short form (7-8 digits) or full EAN-13 form + (13 digits starting with 977). When provided in short form, digits 11-12 + default to "00". When provided in full form, digits 11-12 are preserved. :parameters: issn : String - The issn number as string. + The issn number as string. Can be 7-8 digits (short form) or + 13 digits starting with 977 (full EAN-13 form). writer : barcode.writer Instance The writer to render the barcode (default: SVGWriter). """ name = "ISSN" - digits = 7 + issn_digits = 7 def __init__(self, issn, writer=None) -> None: issn = issn.replace("-", "") - issn = issn[: self.digits] + # Handle full EAN-13 form (13 digits starting with 977) + if len(issn) >= 12 and issn.startswith("977"): + self._sequence_digits = issn[10:12] + issn = issn[3:10] + else: + self._sequence_digits = "00" + issn = issn[: self.issn_digits] self.issn = issn self.issn = f"{issn}{self._calculate_checksum()}" super().__init__(self.make_ean(), writer) @@ -120,7 +131,9 @@ def _calculate_checksum(self): return tmp def make_ean(self): - return f"977{self.issn[:7]}00{self._calculate_checksum()}" + # Return 12 digits: 977 + 7 ISSN digits + 2 sequence digits + # EAN-13 will calculate and append the 13th digit (EAN checksum) + return f"977{self.issn[:7]}{self._sequence_digits}" def __str__(self) -> str: return self.issn diff --git a/docs/changelog.rst b/docs/changelog.rst index 6309ee6..a47c225 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,12 @@ Changelog --------- +updadte +~~~~~~~ +* Fixed ISSN to accept full EAN-13 format (13 digits starting with 977) and + preserve digits 11-12 (sequence variant) instead of always replacing them + with "00". + v0.16.2 ~~~~~~~ * Add support for Python 3.13. diff --git a/tests/test_checksums.py b/tests/test_checksums.py index b97fef7..c321f02 100755 --- a/tests/test_checksums.py +++ b/tests/test_checksums.py @@ -46,3 +46,20 @@ def test_isbn13_checksum() -> None: def test_gs1_128_checksum() -> None: gs1_128 = get_barcode("gs1_128", "00376401856400470087") assert gs1_128.get_fullcode() == "00376401856400470087" + + +def test_issn_short_form_checksum() -> None: + """Test ISSN with short form (7-8 digits).""" + issn = get_barcode("issn", "0317-8471") + assert issn.issn == "03178471" # type: ignore[attr-defined] + # Default sequence digits "00", EAN checksum is calculated by EAN13 + assert issn.get_fullcode() == "9770317847001" + + +def test_issn_full_ean13_form_checksum() -> None: + """Test ISSN with full EAN-13 form, preserving digits 11-12.""" + # Input: 977 + 1234567 (ISSN) + 89 (sequence) + 8 (EAN checksum - ignored) + issn = get_barcode("issn", "9771234567898") + assert issn.issn == "12345679" # type: ignore[attr-defined] + # Sequence digits "89" preserved, EAN checksum recalculated to 8 + assert issn.get_fullcode() == "9771234567898" From d10c26f394209c2d5aa459a28d6e2ef010428275 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 19:05:57 +0100 Subject: [PATCH 02/13] Add EAN-2 and EAN-5 addon support for barcodes based on EAN-13 and EAN-8. FIXES #140 --- barcode/charsets/ean.py | 27 +++++++ barcode/ean.py | 103 ++++++++++++++++++++++-- barcode/isxn.py | 31 ++++++-- docs/changelog.rst | 6 +- docs/getting-started.rst | 40 ++++++++++ tests/test_addon.py | 167 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 tests/test_addon.py diff --git a/barcode/charsets/ean.py b/barcode/charsets/ean.py index afc94c1..4747f56 100644 --- a/barcode/charsets/ean.py +++ b/barcode/charsets/ean.py @@ -52,3 +52,30 @@ "ABABBA", "ABBABA", ) + +# EAN-2/EAN-5 Addon patterns (GS1/ISO standard) +ADDON_START = "1011" # Start guard for addon +ADDON_SEPARATOR = "01" # Separator between addon digits + +# EAN-2 parity patterns: determined by value mod 4 +ADDON2_PARITY = ( + "AA", # 0 + "AB", # 1 + "BA", # 2 + "BB", # 3 +) + +# EAN-5 parity patterns: determined by checksum +ADDON5_PARITY = ( + "BBAAA", # 0 + "BABAA", # 1 + "BAABA", # 2 + "BAAAB", # 3 + "ABBAA", # 4 + "AABBA", # 5 + "AAABB", # 6 + "ABABA", # 7 + "ABAAB", # 8 + "AABAB", # 9 +) + diff --git a/barcode/ean.py b/barcode/ean.py index e001ccc..9ea8450 100755 --- a/barcode/ean.py +++ b/barcode/ean.py @@ -35,6 +35,8 @@ class EuropeanArticleNumber13(Barcode): :param ean: The ean number as string. If the value is too long, it is trimmed. :param writer: The writer to render the barcode (default: SVGWriter). :param no_checksum: Don't calculate the checksum. Use the provided input instead. + :param guardbar: If True, use guard bar markers in the output. + :param addon: Optional 2 or 5 digit addon (EAN-2 or EAN-5). """ name = "EAN-13" @@ -47,6 +49,7 @@ def __init__( writer=None, no_checksum: bool = False, guardbar: bool = False, + addon: str | None = None, ) -> None: if not ean[: self.digits].isdigit(): raise IllegalCharacterError(f"EAN code can only contain numbers {ean}.") @@ -68,6 +71,21 @@ def __init__( self.ean = f"{base}{last}" + # Validate and store addon + self.addon = None + if addon is not None: + addon = addon.strip() + if addon: + if not addon.isdigit(): + raise IllegalCharacterError( + f"Addon can only contain numbers, got {addon}." + ) + if len(addon) not in (2, 5): + raise NumberOfDigitsError( + f"Addon must be 2 or 5 digits, received {len(addon)}." + ) + self.addon = addon + self.guardbar = guardbar if guardbar: self.EDGE = _ean.EDGE.replace("1", "G") @@ -78,12 +96,18 @@ def __init__( self.writer = writer or self.default_writer() def __str__(self) -> str: + if self.addon: + return f"{self.ean} {self.addon}" return self.ean def get_fullcode(self) -> str: if self.guardbar: - return self.ean[0] + " " + self.ean[1:7] + " " + self.ean[7:] + " >" - return self.ean + base = self.ean[0] + " " + self.ean[1:7] + " " + self.ean[7:] + " >" + else: + base = self.ean + if self.addon: + return f"{base} {self.addon}" + return base def calculate_checksum(self, value: str | None = None) -> int: """Calculates and returns the checksum for EAN13-Code. @@ -112,8 +136,60 @@ def build(self) -> list[str]: for number in self.ean[7:]: code += _ean.CODES["C"][int(number)] code += self.EDGE + + # Add addon if present + if self.addon: + code += self._build_addon() + return [code] + def _build_addon(self) -> str: + """Builds the addon barcode pattern (EAN-2 or EAN-5). + + :returns: The addon pattern as string + """ + if not self.addon: + return "" + + if len(self.addon) == 2: + return self._build_addon2() + return self._build_addon5() + + def _build_addon2(self) -> str: + """Builds EAN-2 addon pattern. + + Parity is determined by the 2-digit value mod 4. + """ + value = int(self.addon) + parity = _ean.ADDON2_PARITY[value % 4] + + code = _ean.ADDON_START + for i, digit in enumerate(self.addon): + if i > 0: + code += _ean.ADDON_SEPARATOR + code += _ean.CODES[parity[i]][int(digit)] + return code + + def _build_addon5(self) -> str: + """Builds EAN-5 addon pattern. + + Parity is determined by a checksum calculation. + """ + # Calculate checksum for parity pattern + checksum = 0 + for i, digit in enumerate(self.addon): + weight = 3 if i % 2 == 0 else 9 + checksum += int(digit) * weight + checksum %= 10 + parity = _ean.ADDON5_PARITY[checksum] + + code = _ean.ADDON_START + for i, digit in enumerate(self.addon): + if i > 0: + code += _ean.ADDON_SEPARATOR + code += _ean.CODES[parity[i]][int(digit)] + return code + def to_ascii(self) -> str: """Returns an ascii representation of the barcode. @@ -136,8 +212,10 @@ class EuropeanArticleNumber13WithGuard(EuropeanArticleNumber13): name = "EAN-13 with guards" - def __init__(self, ean, writer=None, no_checksum=False, guardbar=True) -> None: - super().__init__(ean, writer, no_checksum, guardbar) + def __init__( + self, ean, writer=None, no_checksum=False, guardbar=True, addon=None + ) -> None: + super().__init__(ean, writer, no_checksum, guardbar, addon) class JapanArticleNumber(EuropeanArticleNumber13): @@ -167,6 +245,7 @@ class EuropeanArticleNumber8(EuropeanArticleNumber13): :param ean: The ean number as string. :param writer: The writer to render the barcode (default: SVGWriter). + :param addon: Optional 2 or 5 digit addon (EAN-2 or EAN-5). """ name = "EAN-8" @@ -185,12 +264,21 @@ def build(self) -> list[str]: for number in self.ean[4:]: code += _ean.CODES["C"][int(number)] code += self.EDGE + + # Add addon if present + if self.addon: + code += self._build_addon() + return [code] def get_fullcode(self): if self.guardbar: - return "< " + self.ean[:4] + " " + self.ean[4:] + " >" - return self.ean + base = "< " + self.ean[:4] + " " + self.ean[4:] + " >" + else: + base = self.ean + if self.addon: + return f"{base} {self.addon}" + return base class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8): @@ -204,8 +292,9 @@ def __init__( writer=None, no_checksum: bool = False, guardbar: bool = True, + addon: str | None = None, ) -> None: - super().__init__(ean, writer, no_checksum, guardbar) + super().__init__(ean, writer, no_checksum, guardbar, addon) class EuropeanArticleNumber14(EuropeanArticleNumber13): diff --git a/barcode/isxn.py b/barcode/isxn.py index aa069f4..34ef54c 100755 --- a/barcode/isxn.py +++ b/barcode/isxn.py @@ -39,18 +39,23 @@ class InternationalStandardBookNumber13(EuropeanArticleNumber13): The isbn number as string. writer : barcode.writer Instance The writer to render the barcode (default: SVGWriter). + addon : String + Optional 2 or 5 digit addon (EAN-2 or EAN-5). Commonly used for + prices (EAN-5). """ name = "ISBN-13" - def __init__(self, isbn, writer=None, no_checksum=False, guardbar=False) -> None: + def __init__( + self, isbn, writer=None, no_checksum=False, guardbar=False, addon=None + ) -> None: isbn = isbn.replace("-", "") self.isbn13 = isbn if isbn[:3] not in ("978", "979"): raise WrongCountryCodeError("ISBN must start with 978 or 979.") if isbn[:3] == "979" and isbn[3:4] not in ("1", "8"): raise BarcodeError("ISBN must start with 97910 or 97911.") - super().__init__(isbn, writer, no_checksum, guardbar) + super().__init__(isbn, writer, no_checksum, guardbar, addon) class InternationalStandardBookNumber10(InternationalStandardBookNumber13): @@ -62,16 +67,19 @@ class InternationalStandardBookNumber10(InternationalStandardBookNumber13): The isbn number as string. writer : barcode.writer Instance The writer to render the barcode (default: SVGWriter). + addon : String + Optional 2 or 5 digit addon (EAN-2 or EAN-5). Commonly used for + prices (EAN-5). """ name = "ISBN-10" - digits = 9 + isbn10_digits = 9 - def __init__(self, isbn, writer=None) -> None: + def __init__(self, isbn, writer=None, addon=None) -> None: isbn = isbn.replace("-", "") - isbn = isbn[: self.digits] - super().__init__("978" + isbn, writer) + isbn = isbn[: self.isbn10_digits] + super().__init__("978" + isbn, writer, addon=addon) self.isbn10 = isbn self.isbn10 = f"{isbn}{self._calculate_checksum()}" @@ -83,6 +91,8 @@ def _calculate_checksum(self): return tmp def __str__(self) -> str: + if self.addon: + return f"{self.isbn10} {self.addon}" return self.isbn10 @@ -100,13 +110,16 @@ class InternationalStandardSerialNumber(EuropeanArticleNumber13): 13 digits starting with 977 (full EAN-13 form). writer : barcode.writer Instance The writer to render the barcode (default: SVGWriter). + addon : String + Optional 2 or 5 digit addon (EAN-2 or EAN-5). Commonly used for + issue numbers (EAN-2) or prices (EAN-5). """ name = "ISSN" issn_digits = 7 - def __init__(self, issn, writer=None) -> None: + def __init__(self, issn, writer=None, addon=None) -> None: issn = issn.replace("-", "") # Handle full EAN-13 form (13 digits starting with 977) if len(issn) >= 12 and issn.startswith("977"): @@ -117,7 +130,7 @@ def __init__(self, issn, writer=None) -> None: issn = issn[: self.issn_digits] self.issn = issn self.issn = f"{issn}{self._calculate_checksum()}" - super().__init__(self.make_ean(), writer) + super().__init__(self.make_ean(), writer, addon=addon) def _calculate_checksum(self): tmp = ( @@ -136,6 +149,8 @@ def make_ean(self): return f"977{self.issn[:7]}{self._sequence_digits}" def __str__(self) -> str: + if self.addon: + return f"{self.issn} {self.addon}" return self.issn diff --git a/docs/changelog.rst b/docs/changelog.rst index a47c225..1ce8a27 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,8 +1,12 @@ Changelog --------- -updadte +v0.16.3 ~~~~~~~ +* Added support for EAN-2 and EAN-5 addons. These supplemental barcodes can be + added to EAN-13, EAN-8, ISBN-13, ISBN-10, and ISSN barcodes via the ``addon`` + parameter. EAN-2 is commonly used for periodical issue numbers, EAN-5 for + book prices. * Fixed ISSN to accept full EAN-13 format (13 digits starting with 977) and preserve digits 11-12 (sequence variant) instead of always replacing them with "00". diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 49cc47a..0ab4833 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -132,6 +132,46 @@ Using an interactive python interpreter to generate PNG files. You can check the generated files (e.g.: ``ean13_barcode.png``) by opening them with any graphical app (e.g.: Firefox). +Using EAN-2 and EAN-5 Addons +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 0.16.3 + +EAN-2 and EAN-5 are supplemental barcodes that can be added to EAN-13, EAN-8, +ISBN, and ISSN barcodes. They are commonly used for: + +- **EAN-2**: Periodical issue numbers (e.g., magazine week/month) +- **EAN-5**: Suggested retail prices (e.g., book prices) + +.. code:: python + + from barcode import EAN13 + from barcode.isxn import ISSN, ISBN13 + + # EAN-13 with 2-digit addon (e.g., issue number 05) + ean = EAN13("5901234123457", addon="05") + ean.save("ean13_with_addon2") + + # EAN-13 with 5-digit addon (e.g., price $24.95 = 52495) + ean = EAN13("5901234123457", addon="52495") + ean.save("ean13_with_addon5") + + # ISSN with issue number addon + issn = ISSN("03178471", addon="05") + issn.save("issn_with_issue") + + # ISBN with price addon + isbn = ISBN13("978-3-16-148410-0", addon="52495") + isbn.save("isbn_with_price") + +The addon appears in the output of ``get_fullcode()`` separated by a space: + +.. code:: pycon + + >>> ean = EAN13("5901234123457", addon="12") + >>> ean.get_fullcode() + '5901234123457 12' + Command Line usage ~~~~~~~~~~~~~~~~~~ diff --git a/tests/test_addon.py b/tests/test_addon.py new file mode 100644 index 0000000..1770175 --- /dev/null +++ b/tests/test_addon.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import pytest + +from barcode import get_barcode +from barcode.ean import EAN8 +from barcode.ean import EAN13 +from barcode.errors import IllegalCharacterError +from barcode.errors import NumberOfDigitsError +from barcode.isxn import ISBN10 +from barcode.isxn import ISBN13 +from barcode.isxn import ISSN + + +class TestEAN2Addon: + """Tests for EAN-2 addon functionality.""" + + def test_ean13_with_addon2(self) -> None: + """Test EAN-13 with 2-digit addon.""" + ean = EAN13("5901234123457", addon="12") + assert ean.ean == "5901234123457" + assert ean.addon == "12" + assert ean.get_fullcode() == "5901234123457 12" + assert str(ean) == "5901234123457 12" + + def test_ean8_with_addon2(self) -> None: + """Test EAN-8 with 2-digit addon.""" + ean = EAN8("40267708", addon="05") + assert ean.addon == "05" + assert ean.get_fullcode() == "40267708 05" + + def test_addon2_builds_correctly(self) -> None: + """Test that EAN-2 addon pattern is built correctly.""" + ean = EAN13("5901234123457", addon="12") + code = ean.build()[0] + # Main barcode should be there + assert code.startswith("101") # Start guard + # Addon should be appended + assert "1011" in code # Addon start guard + + def test_addon2_parity_mod4(self) -> None: + """Test EAN-2 parity patterns based on value mod 4.""" + # Test different mod 4 values + ean00 = EAN13("5901234123457", addon="00") # 0 % 4 = 0 -> AA + ean01 = EAN13("5901234123457", addon="01") # 1 % 4 = 1 -> AB + ean02 = EAN13("5901234123457", addon="02") # 2 % 4 = 2 -> BA + ean03 = EAN13("5901234123457", addon="03") # 3 % 4 = 3 -> BB + + # Each should build without error + for ean in [ean00, ean01, ean02, ean03]: + code = ean.build()[0] + assert len(code) > 95 # Main EAN-13 + addon + + +class TestEAN5Addon: + """Tests for EAN-5 addon functionality.""" + + def test_ean13_with_addon5(self) -> None: + """Test EAN-13 with 5-digit addon.""" + ean = EAN13("5901234123457", addon="52495") + assert ean.addon == "52495" + assert ean.get_fullcode() == "5901234123457 52495" + assert str(ean) == "5901234123457 52495" + + def test_ean8_with_addon5(self) -> None: + """Test EAN-8 with 5-digit addon.""" + ean = EAN8("40267708", addon="12345") + assert ean.addon == "12345" + assert ean.get_fullcode() == "40267708 12345" + + def test_addon5_builds_correctly(self) -> None: + """Test that EAN-5 addon pattern is built correctly.""" + ean = EAN13("5901234123457", addon="52495") + code = ean.build()[0] + # Main barcode should be there + assert code.startswith("101") # Start guard + # Addon should be appended + assert "1011" in code # Addon start guard + + def test_addon5_price_encoding(self) -> None: + """Test EAN-5 with typical price encoding (e.g., $24.95).""" + # 52495 typically means $24.95 USD (5 = USD, 2495 = price) + ean = EAN13("9780132354189", addon="52495") + assert ean.get_fullcode() == "9780132354189 52495" + + +class TestAddonValidation: + """Tests for addon validation.""" + + def test_addon_must_be_digits(self) -> None: + """Test that addon must contain only digits.""" + with pytest.raises(IllegalCharacterError): + EAN13("5901234123457", addon="1A") + + def test_addon_must_be_2_or_5_digits(self) -> None: + """Test that addon must be exactly 2 or 5 digits.""" + with pytest.raises(NumberOfDigitsError): + EAN13("5901234123457", addon="1") + with pytest.raises(NumberOfDigitsError): + EAN13("5901234123457", addon="123") + with pytest.raises(NumberOfDigitsError): + EAN13("5901234123457", addon="1234") + with pytest.raises(NumberOfDigitsError): + EAN13("5901234123457", addon="123456") + + def test_addon_empty_string_ignored(self) -> None: + """Test that empty addon string is treated as no addon.""" + ean = EAN13("5901234123457", addon="") + assert ean.addon is None + assert ean.get_fullcode() == "5901234123457" + + def test_addon_whitespace_stripped(self) -> None: + """Test that whitespace is stripped from addon.""" + ean = EAN13("5901234123457", addon=" 12 ") + assert ean.addon == "12" + + def test_addon_none_is_valid(self) -> None: + """Test that None addon is valid (no addon).""" + ean = EAN13("5901234123457", addon=None) + assert ean.addon is None + assert ean.get_fullcode() == "5901234123457" + + +class TestISXNWithAddon: + """Tests for ISBN and ISSN with addons.""" + + def test_issn_with_addon2(self) -> None: + """Test ISSN with 2-digit addon (issue number).""" + issn = ISSN("03178471", addon="05") + assert issn.addon == "05" + assert str(issn) == "03178471 05" + assert issn.get_fullcode() == "9770317847001 05" + + def test_issn_with_addon5(self) -> None: + """Test ISSN with 5-digit addon.""" + issn = ISSN("03178471", addon="12345") + assert issn.addon == "12345" + assert issn.get_fullcode() == "9770317847001 12345" + + def test_isbn13_with_addon5(self) -> None: + """Test ISBN-13 with 5-digit addon (price).""" + isbn = ISBN13("978-3-16-148410-0", addon="52495") + assert isbn.addon == "52495" + assert isbn.get_fullcode() == "9783161484100 52495" + + def test_isbn10_with_addon5(self) -> None: + """Test ISBN-10 with 5-digit addon (price).""" + isbn = ISBN10("3-12-517154-7", addon="52495") + assert isbn.addon == "52495" + assert str(isbn) == "3125171547 52495" + assert isbn.get_fullcode() == "9783125171541 52495" + + +class TestGetBarcodeWithAddon: + """Tests for get_barcode function with addon parameter.""" + + def test_get_barcode_ean13_with_addon(self) -> None: + """Test get_barcode with EAN-13 and addon.""" + ean = get_barcode("ean13", "5901234123457", options={"addon": "12"}) + assert ean.addon == "12" + assert ean.get_fullcode() == "5901234123457 12" + + def test_get_barcode_issn_with_addon(self) -> None: + """Test get_barcode with ISSN and addon.""" + issn = get_barcode("issn", "03178471", options={"addon": "05"}) + assert issn.addon == "05" + From cc7d1031901c4eb6316214c18c09362249a300b8 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 19:25:26 +0100 Subject: [PATCH 03/13] Add EAN-2 and EAN-5 addon support for UPC-A (EAN-12) barcodes as per GITN standard. --- barcode/charsets/addons.py | 62 ++++++++++++++++++++++++ barcode/charsets/ean.py | 43 +++++++---------- barcode/charsets/upc.py | 19 ++++++++ barcode/upc.py | 94 ++++++++++++++++++++++++++++++++---- docs/changelog.rst | 4 +- tests/test_addon.py | 97 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 barcode/charsets/addons.py diff --git a/barcode/charsets/addons.py b/barcode/charsets/addons.py new file mode 100644 index 0000000..93342ff --- /dev/null +++ b/barcode/charsets/addons.py @@ -0,0 +1,62 @@ +"""Common addon patterns for EAN-2 and EAN-5 supplemental barcodes. + +These patterns are shared by EAN-13, EAN-8, UPC-A, and related barcode types. +Based on GS1/ISO standard. +""" + +from __future__ import annotations + +# Addon guard patterns +ADDON_START = "1011" # Start guard for addon +ADDON_SEPARATOR = "01" # Separator between addon digits + +# Addon digit encoding (uses A and B parity patterns) +ADDON_CODES = { + "A": ( + "0001101", + "0011001", + "0010011", + "0111101", + "0100011", + "0110001", + "0101111", + "0111011", + "0110111", + "0001011", + ), + "B": ( + "0100111", + "0110011", + "0011011", + "0100001", + "0011101", + "0111001", + "0000101", + "0010001", + "0001001", + "0010111", + ), +} + +# EAN-2 parity patterns: determined by value mod 4 +ADDON2_PARITY = ( + "AA", # 0 + "AB", # 1 + "BA", # 2 + "BB", # 3 +) + +# EAN-5 parity patterns: determined by checksum +ADDON5_PARITY = ( + "BBAAA", # 0 + "BABAA", # 1 + "BAABA", # 2 + "BAAAB", # 3 + "ABBAA", # 4 + "AABBA", # 5 + "AAABB", # 6 + "ABABA", # 7 + "ABAAB", # 8 + "AABAB", # 9 +) + diff --git a/barcode/charsets/ean.py b/barcode/charsets/ean.py index 4747f56..c2ed1da 100644 --- a/barcode/charsets/ean.py +++ b/barcode/charsets/ean.py @@ -1,5 +1,12 @@ from __future__ import annotations +from barcode.charsets.addons import ADDON2_PARITY +from barcode.charsets.addons import ADDON5_PARITY +from barcode.charsets.addons import ADDON_SEPARATOR +from barcode.charsets.addons import ADDON_START + +# Note: Addon codes use CODES["A"] and CODES["B"] defined below + EDGE = "101" MIDDLE = "01010" CODES = { @@ -53,29 +60,15 @@ "ABBABA", ) -# EAN-2/EAN-5 Addon patterns (GS1/ISO standard) -ADDON_START = "1011" # Start guard for addon -ADDON_SEPARATOR = "01" # Separator between addon digits - -# EAN-2 parity patterns: determined by value mod 4 -ADDON2_PARITY = ( - "AA", # 0 - "AB", # 1 - "BA", # 2 - "BB", # 3 -) - -# EAN-5 parity patterns: determined by checksum -ADDON5_PARITY = ( - "BBAAA", # 0 - "BABAA", # 1 - "BAABA", # 2 - "BAAAB", # 3 - "ABBAA", # 4 - "AABBA", # 5 - "AAABB", # 6 - "ABABA", # 7 - "ABAAB", # 8 - "AABAB", # 9 -) +# Re-export addon constants for backwards compatibility +__all__ = [ + "ADDON2_PARITY", + "ADDON5_PARITY", + "ADDON_SEPARATOR", + "ADDON_START", + "CODES", + "EDGE", + "LEFT_PATTERN", + "MIDDLE", +] diff --git a/barcode/charsets/upc.py b/barcode/charsets/upc.py index cb49510..35aa587 100644 --- a/barcode/charsets/upc.py +++ b/barcode/charsets/upc.py @@ -1,5 +1,11 @@ from __future__ import annotations +from barcode.charsets.addons import ADDON2_PARITY +from barcode.charsets.addons import ADDON5_PARITY +from barcode.charsets.addons import ADDON_CODES +from barcode.charsets.addons import ADDON_SEPARATOR +from barcode.charsets.addons import ADDON_START + EDGE = "101" MIDDLE = "01010" CODES = { @@ -28,3 +34,16 @@ "1110100", ), } + +# Re-export addon constants for backwards compatibility +__all__ = [ + "ADDON2_PARITY", + "ADDON5_PARITY", + "ADDON_CODES", + "ADDON_SEPARATOR", + "ADDON_START", + "CODES", + "EDGE", + "MIDDLE", +] + diff --git a/barcode/upc.py b/barcode/upc.py index 060f19f..7bbfa0f 100755 --- a/barcode/upc.py +++ b/barcode/upc.py @@ -25,7 +25,13 @@ class UniversalProductCodeA(Barcode): digits = 11 - def __init__(self, upc, writer=None, make_ean=False) -> None: + def __init__( + self, + upc: str, + writer=None, + make_ean: bool = False, + addon: str | None = None, + ) -> None: """Initializes new UPC-A barcode. :param str upc: The upc number as string. @@ -34,6 +40,7 @@ def __init__(self, upc, writer=None, make_ean=False) -> None: :param bool make_ean: Indicates if a leading zero should be added to the barcode. This converts the UPC into a valid European Article Number (EAN). + :param addon: Optional 2 or 5 digit addon (EAN-2 or EAN-5). """ self.ean = make_ean upc = upc[: self.digits] @@ -45,19 +52,35 @@ def __init__(self, upc, writer=None, make_ean=False) -> None: ) self.upc = upc self.upc = f"{upc}{self.calculate_checksum()}" + + # Validate and store addon + self.addon: str | None = None + if addon is not None: + addon = addon.strip() + if addon: + if not addon.isdigit(): + raise IllegalCharacterError( + f"Addon can only contain numbers, got {addon}." + ) + if len(addon) not in (2, 5): + raise NumberOfDigitsError( + f"Addon must be 2 or 5 digits, received {len(addon)}." + ) + self.addon = addon + self.writer = writer or self.default_writer() def __str__(self) -> str: - if self.ean: - return "0" + self.upc - - return self.upc + base = "0" + self.upc if self.ean else self.upc + if self.addon: + return f"{base} {self.addon}" + return base def get_fullcode(self): - if self.ean: - return "0" + self.upc - - return self.upc + base = "0" + self.upc if self.ean else self.upc + if self.addon: + return f"{base} {self.addon}" + return base def calculate_checksum(self): """Calculates the checksum for UPCA/UPC codes @@ -86,7 +109,7 @@ def build(self) -> list[str]: """ code = _upc.EDGE[:] - for _i, number in enumerate(self.upc[0:6]): + for number in self.upc[0:6]: code += _upc.CODES["L"][int(number)] code += _upc.MIDDLE @@ -96,8 +119,59 @@ def build(self) -> list[str]: code += _upc.EDGE + # Add addon if present + if self.addon: + code += self._build_addon() + return [code] + def _build_addon(self) -> str: + """Builds the addon barcode pattern (EAN-2 or EAN-5). + + :returns: The addon pattern as string + """ + if not self.addon: + return "" + + if len(self.addon) == 2: + return self._build_addon2() + return self._build_addon5() + + def _build_addon2(self) -> str: + """Builds EAN-2 addon pattern. + + Parity is determined by the 2-digit value mod 4. + """ + value = int(self.addon) + parity = _upc.ADDON2_PARITY[value % 4] + + code = _upc.ADDON_START + for i, digit in enumerate(self.addon): + if i > 0: + code += _upc.ADDON_SEPARATOR + code += _upc.ADDON_CODES[parity[i]][int(digit)] + return code + + def _build_addon5(self) -> str: + """Builds EAN-5 addon pattern. + + Parity is determined by a checksum calculation. + """ + # Calculate checksum for parity pattern + checksum = 0 + for i, digit in enumerate(self.addon): + weight = 3 if i % 2 == 0 else 9 + checksum += int(digit) * weight + checksum %= 10 + parity = _upc.ADDON5_PARITY[checksum] + + code = _upc.ADDON_START + for i, digit in enumerate(self.addon): + if i > 0: + code += _upc.ADDON_SEPARATOR + code += _upc.ADDON_CODES[parity[i]][int(digit)] + return code + def to_ascii(self) -> str: """Returns an ascii representation of the barcode. diff --git a/docs/changelog.rst b/docs/changelog.rst index 1ce8a27..6de4988 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,12 +1,14 @@ Changelog --------- -v0.16.3 +current ~~~~~~~ * Added support for EAN-2 and EAN-5 addons. These supplemental barcodes can be added to EAN-13, EAN-8, ISBN-13, ISBN-10, and ISSN barcodes via the ``addon`` parameter. EAN-2 is commonly used for periodical issue numbers, EAN-5 for book prices. +* Added support for EAN-2 and EAN-5 addons to UPC-A barcodes via the ``addon`` + parameter, following the same interface as EAN-13. * Fixed ISSN to accept full EAN-13 format (13 digits starting with 977) and preserve digits 11-12 (sequence variant) instead of always replacing them with "00". diff --git a/tests/test_addon.py b/tests/test_addon.py index 1770175..b4ad617 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -10,6 +10,7 @@ from barcode.isxn import ISBN10 from barcode.isxn import ISBN13 from barcode.isxn import ISSN +from barcode.upc import UPCA class TestEAN2Addon: @@ -165,3 +166,99 @@ def test_get_barcode_issn_with_addon(self) -> None: issn = get_barcode("issn", "03178471", options={"addon": "05"}) assert issn.addon == "05" + def test_get_barcode_upca_with_addon(self) -> None: + """Test get_barcode with UPC-A and addon.""" + upca = get_barcode("upca", "01234567890", options={"addon": "12"}) + assert upca.addon == "12" + assert upca.get_fullcode() == "012345678905 12" + + +class TestUPCAWithAddon: + """Tests for UPC-A with EAN-2 and EAN-5 addons.""" + + def test_upca_with_addon2(self) -> None: + """Test UPC-A with 2-digit addon.""" + upc = UPCA("01234567890", addon="12") + assert upc.upc == "012345678905" + assert upc.addon == "12" + assert upc.get_fullcode() == "012345678905 12" + assert str(upc) == "012345678905 12" + + def test_upca_with_addon5(self) -> None: + """Test UPC-A with 5-digit addon.""" + upc = UPCA("01234567890", addon="52495") + assert upc.addon == "52495" + assert upc.get_fullcode() == "012345678905 52495" + assert str(upc) == "012345678905 52495" + + def test_upca_addon2_builds_correctly(self) -> None: + """Test that UPC-A with EAN-2 addon pattern is built correctly.""" + upc = UPCA("01234567890", addon="12") + code = upc.build()[0] + # Main barcode should be there + assert code.startswith("101") # Start guard + # Addon should be appended + assert "1011" in code # Addon start guard + + def test_upca_addon5_builds_correctly(self) -> None: + """Test that UPC-A with EAN-5 addon pattern is built correctly.""" + upc = UPCA("01234567890", addon="52495") + code = upc.build()[0] + # Main barcode should be there + assert code.startswith("101") # Start guard + # Addon should be appended + assert "1011" in code # Addon start guard + + def test_upca_addon_must_be_digits(self) -> None: + """Test that UPC-A addon must contain only digits.""" + with pytest.raises(IllegalCharacterError): + UPCA("01234567890", addon="1A") + + def test_upca_addon_must_be_2_or_5_digits(self) -> None: + """Test that UPC-A addon must be exactly 2 or 5 digits.""" + with pytest.raises(NumberOfDigitsError): + UPCA("01234567890", addon="1") + with pytest.raises(NumberOfDigitsError): + UPCA("01234567890", addon="123") + with pytest.raises(NumberOfDigitsError): + UPCA("01234567890", addon="1234") + with pytest.raises(NumberOfDigitsError): + UPCA("01234567890", addon="123456") + + def test_upca_addon_empty_string_ignored(self) -> None: + """Test that empty addon string is treated as no addon.""" + upc = UPCA("01234567890", addon="") + assert upc.addon is None + assert upc.get_fullcode() == "012345678905" + + def test_upca_addon_whitespace_stripped(self) -> None: + """Test that whitespace is stripped from UPC-A addon.""" + upc = UPCA("01234567890", addon=" 12 ") + assert upc.addon == "12" + + def test_upca_addon_none_is_valid(self) -> None: + """Test that None addon is valid (no addon) for UPC-A.""" + upc = UPCA("01234567890", addon=None) + assert upc.addon is None + assert upc.get_fullcode() == "012345678905" + + def test_upca_make_ean_with_addon(self) -> None: + """Test UPC-A with make_ean=True and addon.""" + upc = UPCA("01234567890", make_ean=True, addon="12") + assert upc.addon == "12" + assert upc.get_fullcode() == "0012345678905 12" + assert str(upc) == "0012345678905 12" + + def test_upca_addon2_parity_mod4(self) -> None: + """Test UPC-A EAN-2 parity patterns based on value mod 4.""" + # Test different mod 4 values + upc00 = UPCA("01234567890", addon="00") # 0 % 4 = 0 -> AA + upc01 = UPCA("01234567890", addon="01") # 1 % 4 = 1 -> AB + upc02 = UPCA("01234567890", addon="02") # 2 % 4 = 2 -> BA + upc03 = UPCA("01234567890", addon="03") # 3 % 4 = 3 -> BB + + # Each should build without error + for upc in [upc00, upc01, upc02, upc03]: + code = upc.build()[0] + assert len(code) > 95 # Main UPC-A + addon + From f59619fadff1955ca045da3c4cfff4c5f1d7233f Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 20:20:35 +0100 Subject: [PATCH 04/13] Add scannability (image correctness) tests to ensure proper addon rendering. Fix addon rendering by adding quiet zone. --- .github/workflows/tests.yml | 9 + barcode/charsets/addons.py | 1 + barcode/charsets/ean.py | 2 + barcode/charsets/upc.py | 2 + barcode/ean.py | 12 +- barcode/upc.py | 12 +- docs/changelog.rst | 4 + tests/test_scannability.py | 352 ++++++++++++++++++++++++++++++++++++ tox.ini | 4 + 9 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 tests/test_scannability.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31375c4..87ac519 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,6 +27,15 @@ jobs: cache: pip cache-dependency-path: | pyproject.toml + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libzbar0 libcairo2-dev + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install zbar cairo + - name: Install system dependencies (Windows) + if: runner.os == 'Windows' + run: choco install zbar - name: Install test dependency run: pip install tox - name: Run tests diff --git a/barcode/charsets/addons.py b/barcode/charsets/addons.py index 93342ff..3939fcb 100644 --- a/barcode/charsets/addons.py +++ b/barcode/charsets/addons.py @@ -7,6 +7,7 @@ from __future__ import annotations # Addon guard patterns +ADDON_QUIET_ZONE = "000000000" # 9-module separator between main code and addon (GS1 spec) ADDON_START = "1011" # Start guard for addon ADDON_SEPARATOR = "01" # Separator between addon digits diff --git a/barcode/charsets/ean.py b/barcode/charsets/ean.py index c2ed1da..68c758f 100644 --- a/barcode/charsets/ean.py +++ b/barcode/charsets/ean.py @@ -2,6 +2,7 @@ from barcode.charsets.addons import ADDON2_PARITY from barcode.charsets.addons import ADDON5_PARITY +from barcode.charsets.addons import ADDON_QUIET_ZONE from barcode.charsets.addons import ADDON_SEPARATOR from barcode.charsets.addons import ADDON_START @@ -64,6 +65,7 @@ __all__ = [ "ADDON2_PARITY", "ADDON5_PARITY", + "ADDON_QUIET_ZONE", "ADDON_SEPARATOR", "ADDON_START", "CODES", diff --git a/barcode/charsets/upc.py b/barcode/charsets/upc.py index 35aa587..3a0bc22 100644 --- a/barcode/charsets/upc.py +++ b/barcode/charsets/upc.py @@ -3,6 +3,7 @@ from barcode.charsets.addons import ADDON2_PARITY from barcode.charsets.addons import ADDON5_PARITY from barcode.charsets.addons import ADDON_CODES +from barcode.charsets.addons import ADDON_QUIET_ZONE from barcode.charsets.addons import ADDON_SEPARATOR from barcode.charsets.addons import ADDON_START @@ -40,6 +41,7 @@ "ADDON2_PARITY", "ADDON5_PARITY", "ADDON_CODES", + "ADDON_QUIET_ZONE", "ADDON_SEPARATOR", "ADDON_START", "CODES", diff --git a/barcode/ean.py b/barcode/ean.py index 9ea8450..bf9af77 100755 --- a/barcode/ean.py +++ b/barcode/ean.py @@ -146,14 +146,20 @@ def build(self) -> list[str]: def _build_addon(self) -> str: """Builds the addon barcode pattern (EAN-2 or EAN-5). - :returns: The addon pattern as string + :returns: The addon pattern as string (including quiet zone separator) """ if not self.addon: return "" + # Add quiet zone (9 modules) before addon per GS1 specification + code = _ean.ADDON_QUIET_ZONE + if len(self.addon) == 2: - return self._build_addon2() - return self._build_addon5() + code += self._build_addon2() + else: + code += self._build_addon5() + + return code def _build_addon2(self) -> str: """Builds EAN-2 addon pattern. diff --git a/barcode/upc.py b/barcode/upc.py index 7bbfa0f..fb468e4 100755 --- a/barcode/upc.py +++ b/barcode/upc.py @@ -128,14 +128,20 @@ def build(self) -> list[str]: def _build_addon(self) -> str: """Builds the addon barcode pattern (EAN-2 or EAN-5). - :returns: The addon pattern as string + :returns: The addon pattern as string (including quiet zone separator) """ if not self.addon: return "" + # Add quiet zone (9 modules) before addon per GS1 specification + code = _upc.ADDON_QUIET_ZONE + if len(self.addon) == 2: - return self._build_addon2() - return self._build_addon5() + code += self._build_addon2() + else: + code += self._build_addon5() + + return code def _build_addon2(self) -> str: """Builds EAN-2 addon pattern. diff --git a/docs/changelog.rst b/docs/changelog.rst index 6de4988..3199858 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,10 @@ current book prices. * Added support for EAN-2 and EAN-5 addons to UPC-A barcodes via the ``addon`` parameter, following the same interface as EAN-13. +* Addon rendering includes a 9-module quiet zone separator between the + main barcode and the addon, as required by the GS1 specification. This ensures + proper scanning of barcodes with addons. +* Added scannability tests for EAN-2 and EAN-5 addons using the ``pyzbar`` library. * Fixed ISSN to accept full EAN-13 format (13 digits starting with 977) and preserve digits 11-12 (sequence variant) instead of always replacing them with "00". diff --git a/tests/test_scannability.py b/tests/test_scannability.py new file mode 100644 index 0000000..52a460f --- /dev/null +++ b/tests/test_scannability.py @@ -0,0 +1,352 @@ +"""Tests to verify that generated barcodes are scannable by barcode readers. + +These tests generate barcodes, render them as images, and then decode them +using pyzbar to verify that the encoded data matches the expected value. + +Requirements: + - pyzbar: Python wrapper for zbar barcode reader + - cairosvg: For converting SVG to PNG (for SVG writer tests) + - Pillow: For image handling + +System requirements: + - libzbar0: zbar library (apt install libzbar0) + - libcairo2: Cairo library (apt install libcairo2-dev) +""" + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from PIL.Image import Image as PILImage + +# Check for optional dependencies +try: + from PIL import Image + + HAS_PIL = True +except ImportError: + HAS_PIL = False + +try: + import pyzbar.pyzbar as pyzbar + + HAS_PYZBAR = True +except ImportError: + HAS_PYZBAR = False + +try: + import cairosvg + + HAS_CAIROSVG = True +except ImportError: + HAS_CAIROSVG = False + + +import barcode +from barcode.writer import ImageWriter +from barcode.writer import SVGWriter + +# Skip all tests if required dependencies are not available +pytestmark = [ + pytest.mark.skipif(not HAS_PIL, reason="Pillow not installed"), + pytest.mark.skipif(not HAS_PYZBAR, reason="pyzbar not installed"), +] + + +def decode_barcode(image: PILImage) -> list[str]: + """Decode barcodes from an image and return list of decoded values.""" + decoded = pyzbar.decode(image) + return [d.data.decode("utf-8") for d in decoded] + + +def svg_to_image(svg_data: bytes, scale: float = 3.0) -> PILImage: + """Convert SVG data to PIL Image.""" + png_data = cairosvg.svg2png(bytestring=svg_data, scale=scale) + return Image.open(io.BytesIO(png_data)) + + +def generate_svg_barcode( + barcode_type: str, + code: str, + **kwargs, +) -> bytes: + """Generate an SVG barcode and return the SVG data as bytes.""" + bc = barcode.get(barcode_type, code, writer=SVGWriter(), options=kwargs) + buffer = io.BytesIO() + bc.write(buffer) + return buffer.getvalue() + + +def generate_image_barcode( + barcode_type: str, + code: str, + **kwargs, +) -> PILImage: + """Generate a barcode image and return as PIL Image.""" + bc = barcode.get(barcode_type, code, writer=ImageWriter(), options=kwargs) + buffer = io.BytesIO() + bc.write(buffer) + buffer.seek(0) + return Image.open(buffer) + + +@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") +class TestSVGScannability: + """Tests verifying that SVG barcodes are scannable.""" + + @pytest.mark.parametrize( + ("barcode_type", "code", "expected"), + [ + ("ean13", "5901234123457", "5901234123457"), + ("ean8", "9638507", "96385074"), # checksum added + # UPC-A is decoded as EAN-13 with leading 0 by most scanners + ("upca", "04210000526", "0042100005264"), + ], + ) + def test_svg_barcode_is_scannable( + self, + barcode_type: str, + code: str, + expected: str, + ) -> None: + """Verify that SVG barcodes can be decoded to their original value.""" + svg_data = generate_svg_barcode(barcode_type, code) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, f"No barcode detected in {barcode_type} SVG" + assert expected in decoded, ( + f"Expected {expected} in decoded values, got {decoded}" + ) + + @pytest.mark.parametrize( + ("barcode_type", "code", "expected"), + [ + ("ean13", "5901234123457", "5901234123457"), + ("ean8", "9638507", "96385074"), + ], + ) + def test_svg_barcode_with_guardbar_is_scannable( + self, + barcode_type: str, + code: str, + expected: str, + ) -> None: + """Verify that SVG barcodes with guardbars can be decoded.""" + svg_data = generate_svg_barcode(barcode_type, code, guardbar=True) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, ( + f"No barcode detected in {barcode_type} SVG with guardbar" + ) + assert expected in decoded, ( + f"Expected {expected} in decoded values, got {decoded}" + ) + + @pytest.mark.parametrize("addon", ["12", "52495"]) + def test_svg_ean13_with_addon_main_code_is_scannable(self, addon: str) -> None: + """Verify that EAN-13 with addon has scannable main code. + + Note: Most barcode readers decode the main barcode and addon separately, + or may not support addon decoding at all. We verify the main code is readable. + """ + code = "5901234123457" + svg_data = generate_svg_barcode("ean13", code, addon=addon) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, "No barcode detected in EAN-13 with addon" + assert code in decoded, ( + f"Expected main code {code} in decoded values, got {decoded}" + ) + + @pytest.mark.parametrize("addon", ["12", "52495"]) + def test_svg_upca_with_addon_main_code_is_scannable(self, addon: str) -> None: + """Verify that UPC-A with addon has scannable main code.""" + code = "04210000526" + expected = "042100005264" # with checksum + svg_data = generate_svg_barcode("upca", code, addon=addon) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, "No barcode detected in UPC-A with addon" + # UPC-A may be decoded as EAN-13 with leading 0 + decoded_matches = [d for d in decoded if expected in d or d in expected] + assert decoded_matches, f"Expected {expected} in decoded values, got {decoded}" + + +class TestImageScannability: + """Tests verifying that PNG/image barcodes are scannable.""" + + @pytest.mark.parametrize( + ("barcode_type", "code", "expected"), + [ + ("ean13", "5901234123457", "5901234123457"), + ("ean8", "9638507", "96385074"), + ("upca", "04210000526", "042100005264"), + ], + ) + def test_image_barcode_is_scannable( + self, + barcode_type: str, + code: str, + expected: str, + ) -> None: + """Verify that image barcodes can be decoded to their original value.""" + image = generate_image_barcode(barcode_type, code) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, f"No barcode detected in {barcode_type} image" + # UPC-A may be decoded as EAN-13 with leading 0 + decoded_matches = [d for d in decoded if expected in d or d in expected] + assert decoded_matches, f"Expected {expected} in decoded values, got {decoded}" + + @pytest.mark.parametrize( + ("barcode_type", "code", "expected"), + [ + ("ean13", "5901234123457", "5901234123457"), + ("ean8", "9638507", "96385074"), + ], + ) + def test_image_barcode_with_guardbar_is_scannable( + self, + barcode_type: str, + code: str, + expected: str, + ) -> None: + """Verify that image barcodes with guardbars can be decoded.""" + image = generate_image_barcode(barcode_type, code, guardbar=True) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, ( + f"No barcode detected in {barcode_type} image with guardbar" + ) + assert expected in decoded, ( + f"Expected {expected} in decoded values, got {decoded}" + ) + + @pytest.mark.parametrize("addon", ["12", "52495"]) + def test_image_ean13_with_addon_main_code_is_scannable(self, addon: str) -> None: + """Verify that EAN-13 with addon has scannable main code.""" + code = "5901234123457" + image = generate_image_barcode("ean13", code, addon=addon) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, "No barcode detected in EAN-13 with addon" + assert code in decoded, ( + f"Expected main code {code} in decoded values, got {decoded}" + ) + + +class TestISXNScannability: + """Tests verifying that ISBN/ISSN barcodes are scannable.""" + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + @pytest.mark.parametrize( + ("barcode_type", "code", "expected_prefix"), + [ + ("isbn13", "978-3-16-148410-0", "978316148410"), + ("isbn10", "3-12-517154-7", "978312517154"), # converted to ISBN-13 + ("issn", "0317-8471", "9770317847"), # ISSN as EAN-13 + ], + ) + def test_svg_isxn_is_scannable( + self, + barcode_type: str, + code: str, + expected_prefix: str, + ) -> None: + """Verify that ISBN/ISSN SVG barcodes are scannable.""" + svg_data = generate_svg_barcode(barcode_type, code) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, f"No barcode detected in {barcode_type} SVG" + # Check that decoded value starts with expected prefix + matches = [d for d in decoded if d.startswith(expected_prefix)] + assert matches, ( + f"Expected decoded value starting with {expected_prefix}, got {decoded}" + ) + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + @pytest.mark.parametrize("addon", ["52495"]) + def test_svg_isbn_with_addon_is_scannable(self, addon: str) -> None: + """Verify that ISBN-13 with price addon has scannable main code.""" + code = "978-3-16-148410-0" + svg_data = generate_svg_barcode("isbn13", code, addon=addon) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, "No barcode detected in ISBN-13 with addon" + matches = [d for d in decoded if d.startswith("978316148410")] + assert matches, f"Expected ISBN-13 in decoded values, got {decoded}" + + +class TestCode128Scannability: + """Tests verifying that Code128 barcodes are scannable.""" + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + @pytest.mark.parametrize( + "code", + [ + "Example123", + "ABC-123-XYZ", + "1234567890", + ], + ) + def test_svg_code128_is_scannable(self, code: str) -> None: + """Verify that Code128 SVG barcodes are scannable.""" + svg_data = generate_svg_barcode("code128", code) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, f"No barcode detected in Code128 SVG for '{code}'" + assert code in decoded, f"Expected {code} in decoded values, got {decoded}" + + @pytest.mark.parametrize( + "code", + [ + "Example123", + "TEST-456", + ], + ) + def test_image_code128_is_scannable(self, code: str) -> None: + """Verify that Code128 image barcodes are scannable.""" + image = generate_image_barcode("code128", code) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, f"No barcode detected in Code128 image for '{code}'" + assert code in decoded, f"Expected {code} in decoded values, got {decoded}" + + +class TestCode39Scannability: + """Tests verifying that Code39 barcodes are scannable.""" + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + @pytest.mark.parametrize( + "code", + [ + "HELLO", + "ABC123", + "TEST-42", + ], + ) + def test_svg_code39_is_scannable(self, code: str) -> None: + """Verify that Code39 SVG barcodes are scannable. + + Note: Code39 may include a checksum character at the end. + """ + svg_data = generate_svg_barcode("code39", code) + image = svg_to_image(svg_data) + decoded = decode_barcode(image) + + assert len(decoded) >= 1, f"No barcode detected in Code39 SVG for '{code}'" + # Code39 may have checksum character appended + decoded_matches = [d for d in decoded if d.startswith(code)] + assert decoded_matches, f"Expected value starting with {code}, got {decoded}" + diff --git a/tox.ini b/tox.ini index ba2c87a..8beea7e 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,8 @@ deps = pytest pytest-cov images: Pillow + images: pyzbar + images: cairosvg commands = pytest --cov barcode usedevelop = True @@ -14,5 +16,7 @@ usedevelop = True deps = mypy images: Pillow + images: pyzbar + images: cairosvg commands = mypy . usedevelop = True From fcc4a100d9f94aa399196e8364c448135ef8c8fd Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 20:52:34 +0100 Subject: [PATCH 05/13] Update docs/getting-started.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/getting-started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 0ab4833..e2c7a13 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -135,7 +135,7 @@ any graphical app (e.g.: Firefox). Using EAN-2 and EAN-5 Addons ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 0.16.3 +.. versionadded:: |version| EAN-2 and EAN-5 are supplemental barcodes that can be added to EAN-13, EAN-8, ISBN, and ISSN barcodes. They are commonly used for: From f718168b24949db4e372b7fc59c12838355fd83d Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 20:55:27 +0100 Subject: [PATCH 06/13] Update barcode/charsets/ean.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- barcode/charsets/ean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/barcode/charsets/ean.py b/barcode/charsets/ean.py index 68c758f..b151e42 100644 --- a/barcode/charsets/ean.py +++ b/barcode/charsets/ean.py @@ -6,8 +6,8 @@ from barcode.charsets.addons import ADDON_SEPARATOR from barcode.charsets.addons import ADDON_START -# Note: Addon codes use CODES["A"] and CODES["B"] defined below - +# Note: Addon codes are defined in barcode.charsets.addons, but they use the +# same A/B digit encodings as CODES["A"] and CODES["B"] defined below. EDGE = "101" MIDDLE = "01010" CODES = { From cec724b95dfd1d8d4abe1e76cca4bdac29837585 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 20:57:27 +0100 Subject: [PATCH 07/13] Fix too opinionated validation of ISBN-13 code starting with 979 --- barcode/isxn.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/barcode/isxn.py b/barcode/isxn.py index 34ef54c..f261c6f 100755 --- a/barcode/isxn.py +++ b/barcode/isxn.py @@ -53,8 +53,6 @@ def __init__( self.isbn13 = isbn if isbn[:3] not in ("978", "979"): raise WrongCountryCodeError("ISBN must start with 978 or 979.") - if isbn[:3] == "979" and isbn[3:4] not in ("1", "8"): - raise BarcodeError("ISBN must start with 97910 or 97911.") super().__init__(isbn, writer, no_checksum, guardbar, addon) From 55a0b27b1cf49f281b497af7ae606f2b089652d5 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sat, 3 Jan 2026 22:38:37 +0100 Subject: [PATCH 08/13] Refactor duplicated code to a helper module. --- barcode/addon_utils.py | 78 ++++++++++++++++++++++++++++++++++++++ barcode/charsets/addons.py | 3 +- barcode/ean.py | 49 +----------------------- barcode/isxn.py | 1 - barcode/upc.py | 49 +----------------------- 5 files changed, 84 insertions(+), 96 deletions(-) create mode 100644 barcode/addon_utils.py diff --git a/barcode/addon_utils.py b/barcode/addon_utils.py new file mode 100644 index 0000000..41ec551 --- /dev/null +++ b/barcode/addon_utils.py @@ -0,0 +1,78 @@ +"""Utility functions for building EAN-2 and EAN-5 addon barcodes. + +This module provides shared functionality for addon barcode generation +used by EAN and UPC barcode classes. +""" + +from __future__ import annotations + +from barcode.charsets.addons import ADDON2_PARITY +from barcode.charsets.addons import ADDON5_PARITY +from barcode.charsets.addons import ADDON_CODES +from barcode.charsets.addons import ADDON_QUIET_ZONE +from barcode.charsets.addons import ADDON_SEPARATOR +from barcode.charsets.addons import ADDON_START + + +def build_addon(addon: str) -> str: + """Build the complete addon barcode pattern (EAN-2 or EAN-5). + + :param addon: The addon digits (2 or 5 digits) + :returns: The addon pattern as string (including quiet zone separator) + """ + if not addon: + return "" + + # Add quiet zone (9 modules) before addon per GS1 specification + code = ADDON_QUIET_ZONE + + if len(addon) == 2: + code += build_addon2(addon) + else: + code += build_addon5(addon) + + return code + + +def build_addon2(addon: str) -> str: + """Build EAN-2 addon pattern. + + Parity is determined by the 2-digit value mod 4. + + :param addon: The 2-digit addon string + :returns: The EAN-2 addon pattern + """ + value = int(addon) + parity = ADDON2_PARITY[value % 4] + + code = ADDON_START + for i, digit in enumerate(addon): + if i > 0: + code += ADDON_SEPARATOR + code += ADDON_CODES[parity[i]][int(digit)] + return code + + +def build_addon5(addon: str) -> str: + """Build EAN-5 addon pattern. + + Parity is determined by a checksum calculation. + + :param addon: The 5-digit addon string + :returns: The EAN-5 addon pattern + """ + # Calculate checksum for parity pattern + checksum = 0 + for i, digit in enumerate(addon): + weight = 3 if i % 2 == 0 else 9 + checksum += int(digit) * weight + checksum %= 10 + parity = ADDON5_PARITY[checksum] + + code = ADDON_START + for i, digit in enumerate(addon): + if i > 0: + code += ADDON_SEPARATOR + code += ADDON_CODES[parity[i]][int(digit)] + return code + diff --git a/barcode/charsets/addons.py b/barcode/charsets/addons.py index 3939fcb..458da7e 100644 --- a/barcode/charsets/addons.py +++ b/barcode/charsets/addons.py @@ -7,7 +7,8 @@ from __future__ import annotations # Addon guard patterns -ADDON_QUIET_ZONE = "000000000" # 9-module separator between main code and addon (GS1 spec) +# 9-module separator between main code and addon (GS1 spec) +ADDON_QUIET_ZONE = "000000000" ADDON_START = "1011" # Start guard for addon ADDON_SEPARATOR = "01" # Separator between addon digits diff --git a/barcode/ean.py b/barcode/ean.py index bf9af77..2e6fa16 100755 --- a/barcode/ean.py +++ b/barcode/ean.py @@ -8,6 +8,7 @@ __docformat__ = "restructuredtext en" +from barcode import addon_utils from barcode.base import Barcode from barcode.charsets import ean as _ean from barcode.errors import IllegalCharacterError @@ -148,53 +149,7 @@ def _build_addon(self) -> str: :returns: The addon pattern as string (including quiet zone separator) """ - if not self.addon: - return "" - - # Add quiet zone (9 modules) before addon per GS1 specification - code = _ean.ADDON_QUIET_ZONE - - if len(self.addon) == 2: - code += self._build_addon2() - else: - code += self._build_addon5() - - return code - - def _build_addon2(self) -> str: - """Builds EAN-2 addon pattern. - - Parity is determined by the 2-digit value mod 4. - """ - value = int(self.addon) - parity = _ean.ADDON2_PARITY[value % 4] - - code = _ean.ADDON_START - for i, digit in enumerate(self.addon): - if i > 0: - code += _ean.ADDON_SEPARATOR - code += _ean.CODES[parity[i]][int(digit)] - return code - - def _build_addon5(self) -> str: - """Builds EAN-5 addon pattern. - - Parity is determined by a checksum calculation. - """ - # Calculate checksum for parity pattern - checksum = 0 - for i, digit in enumerate(self.addon): - weight = 3 if i % 2 == 0 else 9 - checksum += int(digit) * weight - checksum %= 10 - parity = _ean.ADDON5_PARITY[checksum] - - code = _ean.ADDON_START - for i, digit in enumerate(self.addon): - if i > 0: - code += _ean.ADDON_SEPARATOR - code += _ean.CODES[parity[i]][int(digit)] - return code + return addon_utils.build_addon(self.addon or "") def to_ascii(self) -> str: """Returns an ascii representation of the barcode. diff --git a/barcode/isxn.py b/barcode/isxn.py index f261c6f..16834dc 100755 --- a/barcode/isxn.py +++ b/barcode/isxn.py @@ -25,7 +25,6 @@ from __future__ import annotations from barcode.ean import EuropeanArticleNumber13 -from barcode.errors import BarcodeError from barcode.errors import WrongCountryCodeError __docformat__ = "restructuredtext en" diff --git a/barcode/upc.py b/barcode/upc.py index fb468e4..51b2288 100755 --- a/barcode/upc.py +++ b/barcode/upc.py @@ -9,6 +9,7 @@ from functools import reduce +from barcode import addon_utils from barcode.base import Barcode from barcode.charsets import upc as _upc from barcode.errors import IllegalCharacterError @@ -130,53 +131,7 @@ def _build_addon(self) -> str: :returns: The addon pattern as string (including quiet zone separator) """ - if not self.addon: - return "" - - # Add quiet zone (9 modules) before addon per GS1 specification - code = _upc.ADDON_QUIET_ZONE - - if len(self.addon) == 2: - code += self._build_addon2() - else: - code += self._build_addon5() - - return code - - def _build_addon2(self) -> str: - """Builds EAN-2 addon pattern. - - Parity is determined by the 2-digit value mod 4. - """ - value = int(self.addon) - parity = _upc.ADDON2_PARITY[value % 4] - - code = _upc.ADDON_START - for i, digit in enumerate(self.addon): - if i > 0: - code += _upc.ADDON_SEPARATOR - code += _upc.ADDON_CODES[parity[i]][int(digit)] - return code - - def _build_addon5(self) -> str: - """Builds EAN-5 addon pattern. - - Parity is determined by a checksum calculation. - """ - # Calculate checksum for parity pattern - checksum = 0 - for i, digit in enumerate(self.addon): - weight = 3 if i % 2 == 0 else 9 - checksum += int(digit) * weight - checksum %= 10 - parity = _upc.ADDON5_PARITY[checksum] - - code = _upc.ADDON_START - for i, digit in enumerate(self.addon): - if i > 0: - code += _upc.ADDON_SEPARATOR - code += _upc.ADDON_CODES[parity[i]][int(digit)] - return code + return addon_utils.build_addon(self.addon or "") def to_ascii(self) -> str: """Returns an ascii representation of the barcode. From de552c347763f6cc4d1202e6a622f4ae0719cd11 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sun, 4 Jan 2026 08:58:38 +0100 Subject: [PATCH 09/13] Adjust addon rendering --- barcode/addon_utils.py | 11 ++- barcode/charsets/addons.py | 1 - barcode/charsets/ean.py | 1 - barcode/charsets/upc.py | 1 - barcode/writer.py | 93 ++++++++++++++++++--- docs/changelog.rst | 3 + tests/test_addon.py | 163 +++++++++++++++++++++++++++++++++++++ tests/test_scannability.py | 21 ++++- 8 files changed, 272 insertions(+), 22 deletions(-) diff --git a/barcode/addon_utils.py b/barcode/addon_utils.py index 41ec551..264fb3c 100644 --- a/barcode/addon_utils.py +++ b/barcode/addon_utils.py @@ -40,7 +40,7 @@ def build_addon2(addon: str) -> str: Parity is determined by the 2-digit value mod 4. :param addon: The 2-digit addon string - :returns: The EAN-2 addon pattern + :returns: The EAN-2 addon pattern (using 'A' for addon bars) """ value = int(addon) parity = ADDON2_PARITY[value % 4] @@ -50,7 +50,9 @@ def build_addon2(addon: str) -> str: if i > 0: code += ADDON_SEPARATOR code += ADDON_CODES[parity[i]][int(digit)] - return code + + # Replace '1' with 'A' to mark addon bars for special rendering + return code.replace("1", "A") def build_addon5(addon: str) -> str: @@ -59,7 +61,7 @@ def build_addon5(addon: str) -> str: Parity is determined by a checksum calculation. :param addon: The 5-digit addon string - :returns: The EAN-5 addon pattern + :returns: The EAN-5 addon pattern (using 'A' for addon bars) """ # Calculate checksum for parity pattern checksum = 0 @@ -74,5 +76,6 @@ def build_addon5(addon: str) -> str: if i > 0: code += ADDON_SEPARATOR code += ADDON_CODES[parity[i]][int(digit)] - return code + # Replace '1' with 'A' to mark addon bars for special rendering + return code.replace("1", "A") diff --git a/barcode/charsets/addons.py b/barcode/charsets/addons.py index 458da7e..ee2d31f 100644 --- a/barcode/charsets/addons.py +++ b/barcode/charsets/addons.py @@ -61,4 +61,3 @@ "ABAAB", # 8 "AABAB", # 9 ) - diff --git a/barcode/charsets/ean.py b/barcode/charsets/ean.py index b151e42..fc76674 100644 --- a/barcode/charsets/ean.py +++ b/barcode/charsets/ean.py @@ -73,4 +73,3 @@ "LEFT_PATTERN", "MIDDLE", ] - diff --git a/barcode/charsets/upc.py b/barcode/charsets/upc.py index 3a0bc22..1aee5f6 100644 --- a/barcode/charsets/upc.py +++ b/barcode/charsets/upc.py @@ -48,4 +48,3 @@ "EDGE", "MIDDLE", ] - diff --git a/barcode/writer.py b/barcode/writer.py index 1bed3fc..b7e5190 100755 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -205,8 +205,12 @@ def packed(self, line: str) -> Generator[tuple[int, float], str, None]: '11010111' -> [2, -1, 1, -1, 3] This method will yield a sequence of pairs (width, height_factor). + Height factors: + - 1.0: normal bar + - guard_height_factor: guard bar (taller) + - -1.0: addon bar (shorter, positioned lower to leave space for text above) - :param line: A string matching the writer spec (only contain 0 or 1 or G). + :param line: A string matching the writer spec (can contain 0, 1, G, or A). """ line += " " c = 1 @@ -218,6 +222,9 @@ def packed(self, line: str) -> Generator[tuple[int, float], str, None]: yield (c, 1) elif line[i] == "G": yield (c, self.guard_height_factor) + elif line[i] == "A": + # Addon bar - use negative to signal special handling + yield (c, -1.0) else: yield (-c, self.guard_height_factor) c = 1 @@ -248,6 +255,16 @@ def render(self, code: list[str]): # Flag that indicates if the previous mod was part of an guard block: "was_guard": False, } + + # Track addon bar positions + addon_start_x: float | None = None + addon_end_x: float | None = None + in_addon = False + + # Calculate addon bar offset: space needed for text above addon + # Just the font size should be enough space for text + addon_text_space = pt2mm(self.font_size) + for mod, height_factor in self.packed(line): if mod < 1: color = self.background @@ -258,24 +275,41 @@ def render(self, code: list[str]): # The current guard ended, store its x position text["end"].append(xpos) text["was_guard"] = False - elif not text["was_guard"] and height_factor != 1: + elif not text["was_guard"] and height_factor not in (1, -1.0): # A guard started, store its x position text["start"].append(xpos) text["was_guard"] = True - self.module_height = base_height * height_factor + # Handle addon bars specially + if height_factor == -1.0: + # Track addon bar positions + if not in_addon: + addon_start_x = xpos + in_addon = True + addon_end_x = xpos + self.module_width * abs(mod) + + # Addon bars: same height as main bars, but start lower + # Space for text above = font_size + text_distance + bar_ypos = ypos + addon_text_space + self.module_height = base_height # Same height as main bars + else: + # Normal or guard bars + bar_ypos = ypos + self.module_height = base_height * abs(height_factor) + # remove painting for background colored tiles? self._callbacks["paint_module"]( - xpos, ypos, self.module_width * abs(mod), color + xpos, bar_ypos, self.module_width * abs(mod), color ) xpos += self.module_width * abs(mod) else: - if height_factor != 1: + if height_factor not in (1, -1.0): text["end"].append(xpos) self.module_height = base_height - bxe = xpos - ypos += self.module_height + bxe = xpos # End of all bars (including addon) + bars_ypos = ypos # Save original ypos where bars start + ypos += base_height # Use base_height for text positioning if self.text and self._callbacks["paint_text"] is not None: if not text["start"]: @@ -297,14 +331,50 @@ def render(self, code: list[str]): # The last text block is always put after the last guard end text["xpos"].append(text["end"][-1] + 4 * self.module_width) - ypos += pt2mm(self.font_size) - # Split the ean into its blocks blocks = self.text.split(" ") - for text_, xpos in zip(blocks, text["xpos"]): + + # If the barcode also contains an addon, place the addon label above + # the addon bars (per GS1 layout), while keeping the main EAN text at + # the standard baseline. + addon_code: str | None = None + main_blocks = blocks + if len(blocks) >= 2 and blocks[-2] == ">": + main_blocks = blocks[:-2] + addon_code = blocks[-1] + + ypos += pt2mm(self.font_size) + + # Draw the main EAN blocks on the baseline. + for text_, xpos in zip(main_blocks, text["xpos"]): self.text = text_ self._callbacks["paint_text"](xpos, ypos) + # Draw the addon label above the addon bars. + # The addon digits are placed first, then the '>' marker follows them. + if addon_code is not None: + # Addon bars start at: bars_ypos + addon_text_space + # Text should be in the space BETWEEN bars_ypos and addon bar start + # and it doesn't need margin on top (it's already above the bars) + addon_ypos = bars_ypos + addon_text_space -self.margin_top + + # Center addon text above addon bars + if addon_start_x is not None and addon_end_x is not None: + addon_xpos = (addon_start_x + addon_end_x) / 2 + else: + # Fallback if addon bars not tracked + addon_xpos = text["xpos"][-1] + + # Draw addon digits + self.text = addon_code + self._callbacks["paint_text"](addon_xpos, addon_ypos) + + # Draw '>' marker after the last addon bar slightly after + # the end of bars + marker_xpos = bxe + 2 * self.module_width + self.text = ">" + self._callbacks["paint_text"](marker_xpos, addon_ypos) + return self._callbacks["finish"]() def write(self, content, fp: BinaryIO) -> None: @@ -457,8 +527,7 @@ def _init(self, code: list[str]) -> None: raise RuntimeError("Pillow not found. Cannot create image.") if len(code) != 1: raise NotImplementedError("Only one line of code is supported") - line = code[0] - width, height = self.calculate_size(len(line), 1) + width, height = self.calculate_size(len(code[0]), 1) 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) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3199858..bbcbdc0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,9 @@ current main barcode and the addon, as required by the GS1 specification. This ensures proper scanning of barcodes with addons. * Added scannability tests for EAN-2 and EAN-5 addons using the ``pyzbar`` library. +* Adjusted rendering of EAN barcodes when using ``guardbar=True`` together with an + EAN-2/EAN-5 ``addon``: the addon label is placed above the addon bars per + GS1 layout, rather than being mixed into the main text line. * Fixed ISSN to accept full EAN-13 format (13 digits starting with 977) and preserve digits 11-12 (sequence variant) instead of always replacing them with "00". diff --git a/tests/test_addon.py b/tests/test_addon.py index b4ad617..86c6e9e 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -1,5 +1,9 @@ from __future__ import annotations +import re +from html import unescape +from io import BytesIO + import pytest from barcode import get_barcode @@ -11,6 +15,32 @@ from barcode.isxn import ISBN13 from barcode.isxn import ISSN from barcode.upc import UPCA +from barcode.writer import SVGWriter + + +def _extract_text_elements(svg: str) -> list[dict[str, float | str]]: + """Extract text elements with their content, x, and y positions from SVG.""" + pattern = r']*>(.*?)' + matches = re.findall(pattern, svg) + result: list[dict[str, float | str]] = [] + for x, y, content in matches: + result.append( + { + "x": float(x.replace("mm", "")), + "y": float(y.replace("mm", "")), + "text": unescape(content), + } + ) + return result + + +def _render_svg(ean_code: str, addon: str, guardbar: bool = True) -> str: + """Render EAN13 barcode with given addon to SVG string.""" + ean = EAN13(ean_code, writer=SVGWriter(), guardbar=guardbar, addon=addon) + out = BytesIO() + ean.write(out, options={"write_text": True}) + return out.getvalue().decode("utf-8") + class TestEAN2Addon: @@ -262,3 +292,136 @@ def test_upca_addon2_parity_mod4(self) -> None: code = upc.build()[0] assert len(code) > 95 # Main UPC-A + addon + +class TestGTINCompliantAddonLayout: + """Verify GTIN-compliant layout for guardbar + addon combinations.""" + + def test_ean13_guardbar_addon2_text_order(self) -> None: + """EAN-13 with guardbar and EAN-2: text order must be + main blocks, addon, '>'.""" + svg = _render_svg("5901234123457", "12") + texts = [unescape(t) for t in re.findall(r"]*>(.*?)", svg)] + + # Expected order: 3 main blocks + addon + marker + assert len(texts) == 5 + assert texts[:3] == ["5", "901234", "123457"] # main blocks + assert texts[3] == "12" # addon + assert texts[4] == ">" # marker after addon + + def test_ean13_guardbar_addon5_text_order(self) -> None: + """EAN-13 with guardbar and EAN-5: text order must be + main blocks, addon, '>'.""" + svg = _render_svg("5901234123457", "52495") + texts = [unescape(t) for t in re.findall(r"]*>(.*?)", svg)] + + assert len(texts) == 5 + assert texts[:3] == ["5", "901234", "123457"] + assert texts[3] == "52495" # 5-digit addon + assert texts[4] == ">" + + def test_addon_and_marker_vertical_alignment(self) -> None: + """Addon digits and '>' marker must be at the same + vertical position.""" + svg = _render_svg("5901234123457", "12") + elements = _extract_text_elements(svg) + + # Last two elements are addon and '>' + addon_y = float(elements[-2]["y"]) + marker_y = float(elements[-1]["y"]) + + assert addon_y == marker_y, ( + f"Addon and marker must share y position: " + f"addon={addon_y}, marker={marker_y}" + ) + + def test_addon_positioned_above_main_text(self) -> None: + """Addon label must be positioned above (lower y value) + main EAN text.""" + svg = _render_svg("5901234123457", "12") + elements = _extract_text_elements(svg) + + # First element is first main block, last two are addon and '>' + main_y = float(elements[0]["y"]) + addon_y = float(elements[-2]["y"]) + + # In SVG, lower y = higher on page + assert addon_y < main_y, ( + f"Addon must be above main text: addon_y={addon_y}, main_y={main_y}" + ) + + def test_marker_positioned_after_addon(self) -> None: + """'>' marker must be positioned to the right of addon + digits.""" + svg = _render_svg("5901234123457", "12") + elements = _extract_text_elements(svg) + + addon_x = float(elements[-2]["x"]) + marker_x = float(elements[-1]["x"]) + + # Marker should be to the right (higher x value) + assert marker_x > addon_x, ( + f"Marker must be right of addon: addon_x={addon_x}, marker_x={marker_x}" + ) + + def test_marker_spacing_proportional_to_addon_length(self) -> None: + """Spacing between addon and marker should be proportional + to addon length.""" + svg2 = _render_svg("5901234123457", "12") + svg5 = _render_svg("5901234123457", "52495") + + elements2 = _extract_text_elements(svg2) + elements5 = _extract_text_elements(svg5) + + # Calculate spacing: marker_x - addon_x + spacing2 = float(elements2[-1]["x"]) - float(elements2[-2]["x"]) + spacing5 = float(elements5[-1]["x"]) - float(elements5[-2]["x"]) + + # EAN-5 spacing should be roughly 2.5x EAN-2 (5 chars vs 2 chars) + ratio = spacing5 / spacing2 + assert 2.0 < ratio < 3.0, ( + f"Spacing ratio should be ~2.5 for 5-digit vs 2-digit addon: {ratio:.2f}" + ) + + @pytest.mark.parametrize( + ("code", "addon"), + [ + ("5901234123457", "12"), + ("5901234123457", "52495"), + ("4006381333931", "05"), + ("9780132354189", "51995"), + ], + ) + def test_various_ean_addon_combinations(self, code: str, addon: str) -> None: + """Various EAN+addon combinations must follow GTIN layout + rules.""" + svg = _render_svg(code, addon) + texts = [unescape(t) for t in re.findall(r"]*>(.*?)", svg)] + + # Always: 3 main blocks + addon + '>' + assert len(texts) == 5 + assert texts[-2] == addon + assert texts[-1] == ">" + + # Verify vertical alignment + elements = _extract_text_elements(svg) + assert float(elements[-2]["y"]) == float(elements[-1]["y"]) + assert float(elements[-2]["y"]) < float(elements[0]["y"]) + + +class TestEAN8WithGuardbarAddon: + """Verify EAN-8 with guardbar and addon follows same layout + rules.""" + + def test_ean8_guardbar_addon_text_order(self) -> None: + """EAN-8 with guardbar and addon: proper text order.""" + ean = EAN8("40267708", writer=SVGWriter(), guardbar=True, addon="12") + out = BytesIO() + ean.write(out, options={"write_text": True}) + svg = out.getvalue().decode("utf-8") + + texts = [unescape(t) for t in re.findall(r"]*>(.*?)", svg)] + + # EAN-8: first digit + 2 main blocks + addon + '>' + assert len(texts) == 5 + assert texts[-2] == "12" # addon + assert texts[-1] == ">" # marker diff --git a/tests/test_scannability.py b/tests/test_scannability.py index 52a460f..459948a 100644 --- a/tests/test_scannability.py +++ b/tests/test_scannability.py @@ -13,10 +13,13 @@ - libcairo2: Cairo library (apt install libcairo2-dev) """ +# mypy: ignore-errors + from __future__ import annotations import io from typing import TYPE_CHECKING +from typing import Any import pytest @@ -29,18 +32,21 @@ HAS_PIL = True except ImportError: + Image = None # type: ignore[assignment] HAS_PIL = False try: - import pyzbar.pyzbar as pyzbar + import pyzbar.pyzbar as _pyzbar # type: ignore[import-untyped] + pyzbar: Any = _pyzbar HAS_PYZBAR = True except ImportError: HAS_PYZBAR = False try: - import cairosvg + import cairosvg as _cairosvg # type: ignore[import-untyped] + cairosvg: Any = _cairosvg HAS_CAIROSVG = True except ImportError: HAS_CAIROSVG = False @@ -59,12 +65,19 @@ def decode_barcode(image: PILImage) -> list[str]: """Decode barcodes from an image and return list of decoded values.""" + if not HAS_PYZBAR: + raise RuntimeError("pyzbar not installed") decoded = pyzbar.decode(image) return [d.data.decode("utf-8") for d in decoded] def svg_to_image(svg_data: bytes, scale: float = 3.0) -> PILImage: """Convert SVG data to PIL Image.""" + if not HAS_CAIROSVG: + raise RuntimeError("cairosvg not installed") + if not HAS_PIL or Image is None: + raise RuntimeError("Pillow not installed") + assert Image is not None png_data = cairosvg.svg2png(bytestring=svg_data, scale=scale) return Image.open(io.BytesIO(png_data)) @@ -87,6 +100,9 @@ def generate_image_barcode( **kwargs, ) -> PILImage: """Generate a barcode image and return as PIL Image.""" + if not HAS_PIL or Image is None: + raise RuntimeError("Pillow not installed") + bc = barcode.get(barcode_type, code, writer=ImageWriter(), options=kwargs) buffer = io.BytesIO() bc.write(buffer) @@ -349,4 +365,3 @@ def test_svg_code39_is_scannable(self, code: str) -> None: # Code39 may have checksum character appended decoded_matches = [d for d in decoded if d.startswith(code)] assert decoded_matches, f"Expected value starting with {code}, got {decoded}" - From 6364ab678ab9eedb93b977c35101cbcec684b177 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sun, 4 Jan 2026 09:14:47 +0100 Subject: [PATCH 10/13] Update barcode/writer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- barcode/writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/barcode/writer.py b/barcode/writer.py index b7e5190..02fc752 100755 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -356,7 +356,7 @@ def render(self, code: list[str]): # Addon bars start at: bars_ypos + addon_text_space # Text should be in the space BETWEEN bars_ypos and addon bar start # and it doesn't need margin on top (it's already above the bars) - addon_ypos = bars_ypos + addon_text_space -self.margin_top + addon_ypos = bars_ypos + addon_text_space - self.margin_top # Center addon text above addon bars if addon_start_x is not None and addon_end_x is not None: From 43a053e7e5d58b5b7abf256a375fbe12853b7523 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sun, 4 Jan 2026 11:40:39 +0100 Subject: [PATCH 11/13] Mention in docs that UPC-A also supports addons --- docs/getting-started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started.rst b/docs/getting-started.rst index e2c7a13..e09726a 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -138,7 +138,7 @@ Using EAN-2 and EAN-5 Addons .. versionadded:: |version| EAN-2 and EAN-5 are supplemental barcodes that can be added to EAN-13, EAN-8, -ISBN, and ISSN barcodes. They are commonly used for: +ISBN, ISSN, and UPC-A barcodes. They are commonly used for: - **EAN-2**: Periodical issue numbers (e.g., magazine week/month) - **EAN-5**: Suggested retail prices (e.g., book prices) From ea4a6b4d4bcd12c851f7d21a9c00e43991cf58e5 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sun, 4 Jan 2026 12:35:24 +0100 Subject: [PATCH 12/13] Fix marker position of marker in 'get_fullcode()' for guarded codes --- barcode/ean.py | 18 ++++++------------ barcode/writer.py | 4 ++-- tests/test_addon.py | 40 +++++----------------------------------- 3 files changed, 13 insertions(+), 49 deletions(-) diff --git a/barcode/ean.py b/barcode/ean.py index 2e6fa16..5af8b4b 100755 --- a/barcode/ean.py +++ b/barcode/ean.py @@ -102,13 +102,10 @@ def __str__(self) -> str: return self.ean def get_fullcode(self) -> str: + addon = "" if not self.addon else f" {self.addon}" if self.guardbar: - base = self.ean[0] + " " + self.ean[1:7] + " " + self.ean[7:] + " >" - else: - base = self.ean - if self.addon: - return f"{base} {self.addon}" - return base + return self.ean[0] + " " + self.ean[1:7] + " " + self.ean[7:] + addon + " >" + return f"{self.ean}{addon}" def calculate_checksum(self, value: str | None = None) -> int: """Calculates and returns the checksum for EAN13-Code. @@ -233,13 +230,10 @@ def build(self) -> list[str]: return [code] def get_fullcode(self): + addon = "" if not self.addon else f" {self.addon}" if self.guardbar: - base = "< " + self.ean[:4] + " " + self.ean[4:] + " >" - else: - base = self.ean - if self.addon: - return f"{base} {self.addon}" - return base + return "< " + self.ean[:4] + " " + self.ean[4:] + addon + " >" + return f"{self.ean}{addon}" class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8): diff --git a/barcode/writer.py b/barcode/writer.py index 02fc752..e357c9c 100755 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -339,9 +339,9 @@ def render(self, code: list[str]): # the standard baseline. addon_code: str | None = None main_blocks = blocks - if len(blocks) >= 2 and blocks[-2] == ">": + if len(blocks) >= 2 and blocks[-1] == ">": main_blocks = blocks[:-2] - addon_code = blocks[-1] + addon_code = blocks[-2] ypos += pt2mm(self.font_size) diff --git a/tests/test_addon.py b/tests/test_addon.py index 86c6e9e..7815e7f 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -42,7 +42,6 @@ def _render_svg(ean_code: str, addon: str, guardbar: bool = True) -> str: return out.getvalue().decode("utf-8") - class TestEAN2Addon: """Tests for EAN-2 addon functionality.""" @@ -296,29 +295,6 @@ def test_upca_addon2_parity_mod4(self) -> None: class TestGTINCompliantAddonLayout: """Verify GTIN-compliant layout for guardbar + addon combinations.""" - def test_ean13_guardbar_addon2_text_order(self) -> None: - """EAN-13 with guardbar and EAN-2: text order must be - main blocks, addon, '>'.""" - svg = _render_svg("5901234123457", "12") - texts = [unescape(t) for t in re.findall(r"]*>(.*?)", svg)] - - # Expected order: 3 main blocks + addon + marker - assert len(texts) == 5 - assert texts[:3] == ["5", "901234", "123457"] # main blocks - assert texts[3] == "12" # addon - assert texts[4] == ">" # marker after addon - - def test_ean13_guardbar_addon5_text_order(self) -> None: - """EAN-13 with guardbar and EAN-5: text order must be - main blocks, addon, '>'.""" - svg = _render_svg("5901234123457", "52495") - texts = [unescape(t) for t in re.findall(r"]*>(.*?)", svg)] - - assert len(texts) == 5 - assert texts[:3] == ["5", "901234", "123457"] - assert texts[3] == "52495" # 5-digit addon - assert texts[4] == ">" - def test_addon_and_marker_vertical_alignment(self) -> None: """Addon digits and '>' marker must be at the same vertical position.""" @@ -328,22 +304,13 @@ def test_addon_and_marker_vertical_alignment(self) -> None: # Last two elements are addon and '>' addon_y = float(elements[-2]["y"]) marker_y = float(elements[-1]["y"]) + main_y = float(elements[0]["y"]) assert addon_y == marker_y, ( f"Addon and marker must share y position: " f"addon={addon_y}, marker={marker_y}" ) - def test_addon_positioned_above_main_text(self) -> None: - """Addon label must be positioned above (lower y value) - main EAN text.""" - svg = _render_svg("5901234123457", "12") - elements = _extract_text_elements(svg) - - # First element is first main block, last two are addon and '>' - main_y = float(elements[0]["y"]) - addon_y = float(elements[-2]["y"]) - # In SVG, lower y = higher on page assert addon_y < main_y, ( f"Addon must be above main text: addon_y={addon_y}, main_y={main_y}" @@ -421,7 +388,10 @@ def test_ean8_guardbar_addon_text_order(self) -> None: texts = [unescape(t) for t in re.findall(r"]*>(.*?)", svg)] - # EAN-8: first digit + 2 main blocks + addon + '>' + # EAN-8: "<" + 2 main blocks + addon + '>' assert len(texts) == 5 + assert texts[0] == "<" # marker assert texts[-2] == "12" # addon assert texts[-1] == ">" # marker + + assert ean.get_fullcode() == "< 4026 7708 12 >" From 9b844ee370a6b5f1b9b766b5eab893c173428f06 Mon Sep 17 00:00:00 2001 From: Dominik Kozaczko Date: Sun, 4 Jan 2026 17:17:40 +0100 Subject: [PATCH 13/13] Fix guard marker for eans with and without addons; fix addon rendering without guardlines. --- barcode/writer.py | 86 ++++++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/barcode/writer.py b/barcode/writer.py index e357c9c..5f4f22c 100755 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -312,68 +312,76 @@ def render(self, code: list[str]): ypos += base_height # Use base_height for text positioning if self.text and self._callbacks["paint_text"] is not None: + # Parse text blocks and detect addon + blocks = self.text.split(" ") + has_guard = blocks[-1] == ">" + working_blocks = blocks[:-1] if has_guard else blocks + + # Check if last block is addon (2 or 5 digits) + addon_code: str | None = None + if working_blocks and len(working_blocks[-1]) in (2, 5): + addon_code = working_blocks[-1] + working_blocks = working_blocks[:-1] + + # Calculate addon text position if addon exists + addon_ypos = bars_ypos + addon_text_space - self.margin_top + if addon_start_x is not None and addon_end_x is not None: + addon_xpos = (addon_start_x + addon_end_x) / 2 + else: + addon_xpos = bxe # Fallback + if not text["start"]: - # If we don't have any start value, print the entire ean + # No guards - single centered text line ypos += self.text_distance - xpos = bxs + (bxe - bxs) / 2.0 if self.center_text else bxs - self._callbacks["paint_text"](xpos, ypos) + + if addon_code is not None and addon_start_x is not None: + # Draw main text centered over main barcode (excluding addon) + main_end_x = addon_start_x - 9 * self.module_width + main_text = " ".join(working_blocks) + xpos = bxs + (main_end_x - bxs) / 2.0 if self.center_text else bxs + self.text = main_text + self._callbacks["paint_text"](xpos, ypos) + + # Draw addon text above addon bars + self.text = addon_code + self._callbacks["paint_text"](addon_xpos, addon_ypos) + else: + # No addon - draw full text centered + xpos = bxs + (bxe - bxs) / 2.0 if self.center_text else bxs + self._callbacks["paint_text"](xpos, ypos) else: - # Else, divide the ean into blocks and print each block - # in the expected position. + # Guards present - divide text into positioned blocks text["xpos"] = [bxs - 4 * self.module_width] - # Calculates the position of the text by getting the difference - # between a guard end and the next start + # Calculate positions between guard ends and starts text["start"].pop(0) for s, e in zip(text["start"], text["end"]): text["xpos"].append(e + (s - e) / 2) - # The last text block is always put after the last guard end + # Last text block after last guard end text["xpos"].append(text["end"][-1] + 4 * self.module_width) - # Split the ean into its blocks - blocks = self.text.split(" ") - - # If the barcode also contains an addon, place the addon label above - # the addon bars (per GS1 layout), while keeping the main EAN text at - # the standard baseline. - addon_code: str | None = None - main_blocks = blocks - if len(blocks) >= 2 and blocks[-1] == ">": - main_blocks = blocks[:-2] - addon_code = blocks[-2] - ypos += pt2mm(self.font_size) - # Draw the main EAN blocks on the baseline. - for text_, xpos in zip(main_blocks, text["xpos"]): + # Draw main EAN blocks on the baseline + for text_, xpos in zip(working_blocks, text["xpos"]): self.text = text_ self._callbacks["paint_text"](xpos, ypos) - # Draw the addon label above the addon bars. - # The addon digits are placed first, then the '>' marker follows them. + # Draw addon and/or guard marker if present if addon_code is not None: - # Addon bars start at: bars_ypos + addon_text_space - # Text should be in the space BETWEEN bars_ypos and addon bar start - # and it doesn't need margin on top (it's already above the bars) - addon_ypos = bars_ypos + addon_text_space - self.margin_top - - # Center addon text above addon bars - if addon_start_x is not None and addon_end_x is not None: - addon_xpos = (addon_start_x + addon_end_x) / 2 - else: - # Fallback if addon bars not tracked - addon_xpos = text["xpos"][-1] - - # Draw addon digits self.text = addon_code self._callbacks["paint_text"](addon_xpos, addon_ypos) - # Draw '>' marker after the last addon bar slightly after - # the end of bars + # Draw '>' marker after the last addon bar marker_xpos = bxe + 2 * self.module_width self.text = ">" self._callbacks["paint_text"](marker_xpos, addon_ypos) + elif has_guard: + # No addon, but guard present - draw '>' on main baseline + marker_xpos = bxe + 2 * self.module_width + self.text = ">" + self._callbacks["paint_text"](marker_xpos, ypos) return self._callbacks["finish"]()