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/.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/addon_utils.py b/barcode/addon_utils.py new file mode 100644 index 0000000..264fb3c --- /dev/null +++ b/barcode/addon_utils.py @@ -0,0 +1,81 @@ +"""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 (using 'A' for addon bars) + """ + 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)] + + # Replace '1' with 'A' to mark addon bars for special rendering + return code.replace("1", "A") + + +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 (using 'A' for addon bars) + """ + # 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)] + + # 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 new file mode 100644 index 0000000..ee2d31f --- /dev/null +++ b/barcode/charsets/addons.py @@ -0,0 +1,63 @@ +"""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 +# 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 + +# 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 afc94c1..fc76674 100644 --- a/barcode/charsets/ean.py +++ b/barcode/charsets/ean.py @@ -1,5 +1,13 @@ from __future__ import annotations +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 + +# 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 = { @@ -52,3 +60,16 @@ "ABABBA", "ABBABA", ) + +# Re-export addon constants for backwards compatibility +__all__ = [ + "ADDON2_PARITY", + "ADDON5_PARITY", + "ADDON_QUIET_ZONE", + "ADDON_SEPARATOR", + "ADDON_START", + "CODES", + "EDGE", + "LEFT_PATTERN", + "MIDDLE", +] diff --git a/barcode/charsets/upc.py b/barcode/charsets/upc.py index cb49510..1aee5f6 100644 --- a/barcode/charsets/upc.py +++ b/barcode/charsets/upc.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_CODES +from barcode.charsets.addons import ADDON_QUIET_ZONE +from barcode.charsets.addons import ADDON_SEPARATOR +from barcode.charsets.addons import ADDON_START + EDGE = "101" MIDDLE = "01010" CODES = { @@ -28,3 +35,16 @@ "1110100", ), } + +# Re-export addon constants for backwards compatibility +__all__ = [ + "ADDON2_PARITY", + "ADDON5_PARITY", + "ADDON_CODES", + "ADDON_QUIET_ZONE", + "ADDON_SEPARATOR", + "ADDON_START", + "CODES", + "EDGE", + "MIDDLE", +] diff --git a/barcode/ean.py b/barcode/ean.py index e001ccc..5af8b4b 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 @@ -35,6 +36,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 +50,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 +72,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 +97,15 @@ 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: + addon = "" if not self.addon else f" {self.addon}" if self.guardbar: - return self.ean[0] + " " + self.ean[1:7] + " " + self.ean[7:] + " >" - return self.ean + 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. @@ -112,8 +134,20 @@ 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 (including quiet zone separator) + """ + return addon_utils.build_addon(self.addon or "") + def to_ascii(self) -> str: """Returns an ascii representation of the barcode. @@ -136,8 +170,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 +203,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 +222,18 @@ 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): + addon = "" if not self.addon else f" {self.addon}" if self.guardbar: - return "< " + self.ean[:4] + " " + self.ean[4:] + " >" - return self.ean + return "< " + self.ean[:4] + " " + self.ean[4:] + addon + " >" + return f"{self.ean}{addon}" class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8): @@ -204,8 +247,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 e74ea58..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" @@ -39,18 +38,21 @@ 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 +64,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,30 +88,46 @@ def _calculate_checksum(self): return tmp def __str__(self) -> str: + if self.addon: + return f"{self.isbn10} {self.addon}" return self.isbn10 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). + 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" - digits = 7 + issn_digits = 7 - def __init__(self, issn, writer=None) -> None: + def __init__(self, issn, writer=None, addon=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) + super().__init__(self.make_ean(), writer, addon=addon) def _calculate_checksum(self): tmp = ( @@ -120,9 +141,13 @@ 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: + if self.addon: + return f"{self.issn} {self.addon}" return self.issn diff --git a/barcode/upc.py b/barcode/upc.py index 060f19f..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 @@ -25,7 +26,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 +41,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 +53,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 +110,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 +120,19 @@ 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 (including quiet zone separator) + """ + return addon_utils.build_addon(self.addon or "") + def to_ascii(self) -> str: """Returns an ascii representation of the barcode. diff --git a/barcode/writer.py b/barcode/writer.py index 1bed3fc..5f4f22c 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,53 +275,114 @@ 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: + # 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) ypos += pt2mm(self.font_size) - # Split the ean into its blocks - blocks = self.text.split(" ") - for text_, xpos in zip(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 addon and/or guard marker if present + if addon_code is not None: + self.text = addon_code + self._callbacks["paint_text"](addon_xpos, addon_ypos) + + # 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"]() def write(self, content, fp: BinaryIO) -> None: @@ -457,8 +535,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 6309ee6..bbcbdc0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,25 @@ Changelog --------- +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. +* 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. +* 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". + v0.16.2 ~~~~~~~ * Add support for Python 3.13. diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 49cc47a..e09726a 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:: |version| + +EAN-2 and EAN-5 are supplemental barcodes that can be added to EAN-13, EAN-8, +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) + +.. 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..7815e7f --- /dev/null +++ b/tests/test_addon.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import re +from html import unescape +from io import BytesIO + +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 +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: + """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" + + 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 + + +class TestGTINCompliantAddonLayout: + """Verify GTIN-compliant layout for guardbar + addon combinations.""" + + 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"]) + 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}" + ) + + # 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: "<" + 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 >" 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" diff --git a/tests/test_scannability.py b/tests/test_scannability.py new file mode 100644 index 0000000..459948a --- /dev/null +++ b/tests/test_scannability.py @@ -0,0 +1,367 @@ +"""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) +""" + +# mypy: ignore-errors + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING +from typing import Any + +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: + Image = None # type: ignore[assignment] + HAS_PIL = False + +try: + import pyzbar.pyzbar as _pyzbar # type: ignore[import-untyped] + + pyzbar: Any = _pyzbar + HAS_PYZBAR = True +except ImportError: + HAS_PYZBAR = False + +try: + import cairosvg as _cairosvg # type: ignore[import-untyped] + + cairosvg: Any = _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.""" + 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)) + + +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.""" + 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) + 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