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