Skip to content

Commit de552c3

Browse files
committed
Adjust addon rendering
1 parent 55a0b27 commit de552c3

File tree

8 files changed

+272
-22
lines changed

8 files changed

+272
-22
lines changed

barcode/addon_utils.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def build_addon2(addon: str) -> str:
4040
Parity is determined by the 2-digit value mod 4.
4141
4242
:param addon: The 2-digit addon string
43-
:returns: The EAN-2 addon pattern
43+
:returns: The EAN-2 addon pattern (using 'A' for addon bars)
4444
"""
4545
value = int(addon)
4646
parity = ADDON2_PARITY[value % 4]
@@ -50,7 +50,9 @@ def build_addon2(addon: str) -> str:
5050
if i > 0:
5151
code += ADDON_SEPARATOR
5252
code += ADDON_CODES[parity[i]][int(digit)]
53-
return code
53+
54+
# Replace '1' with 'A' to mark addon bars for special rendering
55+
return code.replace("1", "A")
5456

5557

5658
def build_addon5(addon: str) -> str:
@@ -59,7 +61,7 @@ def build_addon5(addon: str) -> str:
5961
Parity is determined by a checksum calculation.
6062
6163
:param addon: The 5-digit addon string
62-
:returns: The EAN-5 addon pattern
64+
:returns: The EAN-5 addon pattern (using 'A' for addon bars)
6365
"""
6466
# Calculate checksum for parity pattern
6567
checksum = 0
@@ -74,5 +76,6 @@ def build_addon5(addon: str) -> str:
7476
if i > 0:
7577
code += ADDON_SEPARATOR
7678
code += ADDON_CODES[parity[i]][int(digit)]
77-
return code
7879

80+
# Replace '1' with 'A' to mark addon bars for special rendering
81+
return code.replace("1", "A")

barcode/charsets/addons.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,3 @@
6161
"ABAAB", # 8
6262
"AABAB", # 9
6363
)
64-

barcode/charsets/ean.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,3 @@
7373
"LEFT_PATTERN",
7474
"MIDDLE",
7575
]
76-

barcode/charsets/upc.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,3 @@
4848
"EDGE",
4949
"MIDDLE",
5050
]
51-

barcode/writer.py

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,12 @@ def packed(self, line: str) -> Generator[tuple[int, float], str, None]:
205205
'11010111' -> [2, -1, 1, -1, 3]
206206
207207
This method will yield a sequence of pairs (width, height_factor).
208+
Height factors:
209+
- 1.0: normal bar
210+
- guard_height_factor: guard bar (taller)
211+
- -1.0: addon bar (shorter, positioned lower to leave space for text above)
208212
209-
:param line: A string matching the writer spec (only contain 0 or 1 or G).
213+
:param line: A string matching the writer spec (can contain 0, 1, G, or A).
210214
"""
211215
line += " "
212216
c = 1
@@ -218,6 +222,9 @@ def packed(self, line: str) -> Generator[tuple[int, float], str, None]:
218222
yield (c, 1)
219223
elif line[i] == "G":
220224
yield (c, self.guard_height_factor)
225+
elif line[i] == "A":
226+
# Addon bar - use negative to signal special handling
227+
yield (c, -1.0)
221228
else:
222229
yield (-c, self.guard_height_factor)
223230
c = 1
@@ -248,6 +255,16 @@ def render(self, code: list[str]):
248255
# Flag that indicates if the previous mod was part of an guard block:
249256
"was_guard": False,
250257
}
258+
259+
# Track addon bar positions
260+
addon_start_x: float | None = None
261+
addon_end_x: float | None = None
262+
in_addon = False
263+
264+
# Calculate addon bar offset: space needed for text above addon
265+
# Just the font size should be enough space for text
266+
addon_text_space = pt2mm(self.font_size)
267+
251268
for mod, height_factor in self.packed(line):
252269
if mod < 1:
253270
color = self.background
@@ -258,24 +275,41 @@ def render(self, code: list[str]):
258275
# The current guard ended, store its x position
259276
text["end"].append(xpos)
260277
text["was_guard"] = False
261-
elif not text["was_guard"] and height_factor != 1:
278+
elif not text["was_guard"] and height_factor not in (1, -1.0):
262279
# A guard started, store its x position
263280
text["start"].append(xpos)
264281
text["was_guard"] = True
265282

266-
self.module_height = base_height * height_factor
283+
# Handle addon bars specially
284+
if height_factor == -1.0:
285+
# Track addon bar positions
286+
if not in_addon:
287+
addon_start_x = xpos
288+
in_addon = True
289+
addon_end_x = xpos + self.module_width * abs(mod)
290+
291+
# Addon bars: same height as main bars, but start lower
292+
# Space for text above = font_size + text_distance
293+
bar_ypos = ypos + addon_text_space
294+
self.module_height = base_height # Same height as main bars
295+
else:
296+
# Normal or guard bars
297+
bar_ypos = ypos
298+
self.module_height = base_height * abs(height_factor)
299+
267300
# remove painting for background colored tiles?
268301
self._callbacks["paint_module"](
269-
xpos, ypos, self.module_width * abs(mod), color
302+
xpos, bar_ypos, self.module_width * abs(mod), color
270303
)
271304
xpos += self.module_width * abs(mod)
272305
else:
273-
if height_factor != 1:
306+
if height_factor not in (1, -1.0):
274307
text["end"].append(xpos)
275308
self.module_height = base_height
276309

277-
bxe = xpos
278-
ypos += self.module_height
310+
bxe = xpos # End of all bars (including addon)
311+
bars_ypos = ypos # Save original ypos where bars start
312+
ypos += base_height # Use base_height for text positioning
279313

280314
if self.text and self._callbacks["paint_text"] is not None:
281315
if not text["start"]:
@@ -297,14 +331,50 @@ def render(self, code: list[str]):
297331
# The last text block is always put after the last guard end
298332
text["xpos"].append(text["end"][-1] + 4 * self.module_width)
299333

300-
ypos += pt2mm(self.font_size)
301-
302334
# Split the ean into its blocks
303335
blocks = self.text.split(" ")
304-
for text_, xpos in zip(blocks, text["xpos"]):
336+
337+
# If the barcode also contains an addon, place the addon label above
338+
# the addon bars (per GS1 layout), while keeping the main EAN text at
339+
# the standard baseline.
340+
addon_code: str | None = None
341+
main_blocks = blocks
342+
if len(blocks) >= 2 and blocks[-2] == ">":
343+
main_blocks = blocks[:-2]
344+
addon_code = blocks[-1]
345+
346+
ypos += pt2mm(self.font_size)
347+
348+
# Draw the main EAN blocks on the baseline.
349+
for text_, xpos in zip(main_blocks, text["xpos"]):
305350
self.text = text_
306351
self._callbacks["paint_text"](xpos, ypos)
307352

353+
# Draw the addon label above the addon bars.
354+
# The addon digits are placed first, then the '>' marker follows them.
355+
if addon_code is not None:
356+
# Addon bars start at: bars_ypos + addon_text_space
357+
# Text should be in the space BETWEEN bars_ypos and addon bar start
358+
# and it doesn't need margin on top (it's already above the bars)
359+
addon_ypos = bars_ypos + addon_text_space -self.margin_top
360+
361+
# Center addon text above addon bars
362+
if addon_start_x is not None and addon_end_x is not None:
363+
addon_xpos = (addon_start_x + addon_end_x) / 2
364+
else:
365+
# Fallback if addon bars not tracked
366+
addon_xpos = text["xpos"][-1]
367+
368+
# Draw addon digits
369+
self.text = addon_code
370+
self._callbacks["paint_text"](addon_xpos, addon_ypos)
371+
372+
# Draw '>' marker after the last addon bar slightly after
373+
# the end of bars
374+
marker_xpos = bxe + 2 * self.module_width
375+
self.text = ">"
376+
self._callbacks["paint_text"](marker_xpos, addon_ypos)
377+
308378
return self._callbacks["finish"]()
309379

310380
def write(self, content, fp: BinaryIO) -> None:
@@ -457,8 +527,7 @@ def _init(self, code: list[str]) -> None:
457527
raise RuntimeError("Pillow not found. Cannot create image.")
458528
if len(code) != 1:
459529
raise NotImplementedError("Only one line of code is supported")
460-
line = code[0]
461-
width, height = self.calculate_size(len(line), 1)
530+
width, height = self.calculate_size(len(code[0]), 1)
462531
size = (int(mm2px(width, self.dpi)), int(mm2px(height, self.dpi)))
463532
self._image = Image.new(self.mode, size, self.background)
464533
self._draw = ImageDraw.Draw(self._image)

docs/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ current
1313
main barcode and the addon, as required by the GS1 specification. This ensures
1414
proper scanning of barcodes with addons.
1515
* Added scannability tests for EAN-2 and EAN-5 addons using the ``pyzbar`` library.
16+
* Adjusted rendering of EAN barcodes when using ``guardbar=True`` together with an
17+
EAN-2/EAN-5 ``addon``: the addon label is placed above the addon bars per
18+
GS1 layout, rather than being mixed into the main text line.
1619
* Fixed ISSN to accept full EAN-13 format (13 digits starting with 977) and
1720
preserve digits 11-12 (sequence variant) instead of always replacing them
1821
with "00".

tests/test_addon.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from __future__ import annotations
22

3+
import re
4+
from html import unescape
5+
from io import BytesIO
6+
37
import pytest
48

59
from barcode import get_barcode
@@ -11,6 +15,32 @@
1115
from barcode.isxn import ISBN13
1216
from barcode.isxn import ISSN
1317
from barcode.upc import UPCA
18+
from barcode.writer import SVGWriter
19+
20+
21+
def _extract_text_elements(svg: str) -> list[dict[str, float | str]]:
22+
"""Extract text elements with their content, x, and y positions from SVG."""
23+
pattern = r'<text\s+x="([^"]+)"\s+y="([^"]+)"[^>]*>(.*?)</text>'
24+
matches = re.findall(pattern, svg)
25+
result: list[dict[str, float | str]] = []
26+
for x, y, content in matches:
27+
result.append(
28+
{
29+
"x": float(x.replace("mm", "")),
30+
"y": float(y.replace("mm", "")),
31+
"text": unescape(content),
32+
}
33+
)
34+
return result
35+
36+
37+
def _render_svg(ean_code: str, addon: str, guardbar: bool = True) -> str:
38+
"""Render EAN13 barcode with given addon to SVG string."""
39+
ean = EAN13(ean_code, writer=SVGWriter(), guardbar=guardbar, addon=addon)
40+
out = BytesIO()
41+
ean.write(out, options={"write_text": True})
42+
return out.getvalue().decode("utf-8")
43+
1444

1545

1646
class TestEAN2Addon:
@@ -262,3 +292,136 @@ def test_upca_addon2_parity_mod4(self) -> None:
262292
code = upc.build()[0]
263293
assert len(code) > 95 # Main UPC-A + addon
264294

295+
296+
class TestGTINCompliantAddonLayout:
297+
"""Verify GTIN-compliant layout for guardbar + addon combinations."""
298+
299+
def test_ean13_guardbar_addon2_text_order(self) -> None:
300+
"""EAN-13 with guardbar and EAN-2: text order must be
301+
main blocks, addon, '>'."""
302+
svg = _render_svg("5901234123457", "12")
303+
texts = [unescape(t) for t in re.findall(r"<text[^>]*>(.*?)</text>", svg)]
304+
305+
# Expected order: 3 main blocks + addon + marker
306+
assert len(texts) == 5
307+
assert texts[:3] == ["5", "901234", "123457"] # main blocks
308+
assert texts[3] == "12" # addon
309+
assert texts[4] == ">" # marker after addon
310+
311+
def test_ean13_guardbar_addon5_text_order(self) -> None:
312+
"""EAN-13 with guardbar and EAN-5: text order must be
313+
main blocks, addon, '>'."""
314+
svg = _render_svg("5901234123457", "52495")
315+
texts = [unescape(t) for t in re.findall(r"<text[^>]*>(.*?)</text>", svg)]
316+
317+
assert len(texts) == 5
318+
assert texts[:3] == ["5", "901234", "123457"]
319+
assert texts[3] == "52495" # 5-digit addon
320+
assert texts[4] == ">"
321+
322+
def test_addon_and_marker_vertical_alignment(self) -> None:
323+
"""Addon digits and '>' marker must be at the same
324+
vertical position."""
325+
svg = _render_svg("5901234123457", "12")
326+
elements = _extract_text_elements(svg)
327+
328+
# Last two elements are addon and '>'
329+
addon_y = float(elements[-2]["y"])
330+
marker_y = float(elements[-1]["y"])
331+
332+
assert addon_y == marker_y, (
333+
f"Addon and marker must share y position: "
334+
f"addon={addon_y}, marker={marker_y}"
335+
)
336+
337+
def test_addon_positioned_above_main_text(self) -> None:
338+
"""Addon label must be positioned above (lower y value)
339+
main EAN text."""
340+
svg = _render_svg("5901234123457", "12")
341+
elements = _extract_text_elements(svg)
342+
343+
# First element is first main block, last two are addon and '>'
344+
main_y = float(elements[0]["y"])
345+
addon_y = float(elements[-2]["y"])
346+
347+
# In SVG, lower y = higher on page
348+
assert addon_y < main_y, (
349+
f"Addon must be above main text: addon_y={addon_y}, main_y={main_y}"
350+
)
351+
352+
def test_marker_positioned_after_addon(self) -> None:
353+
"""'>' marker must be positioned to the right of addon
354+
digits."""
355+
svg = _render_svg("5901234123457", "12")
356+
elements = _extract_text_elements(svg)
357+
358+
addon_x = float(elements[-2]["x"])
359+
marker_x = float(elements[-1]["x"])
360+
361+
# Marker should be to the right (higher x value)
362+
assert marker_x > addon_x, (
363+
f"Marker must be right of addon: addon_x={addon_x}, marker_x={marker_x}"
364+
)
365+
366+
def test_marker_spacing_proportional_to_addon_length(self) -> None:
367+
"""Spacing between addon and marker should be proportional
368+
to addon length."""
369+
svg2 = _render_svg("5901234123457", "12")
370+
svg5 = _render_svg("5901234123457", "52495")
371+
372+
elements2 = _extract_text_elements(svg2)
373+
elements5 = _extract_text_elements(svg5)
374+
375+
# Calculate spacing: marker_x - addon_x
376+
spacing2 = float(elements2[-1]["x"]) - float(elements2[-2]["x"])
377+
spacing5 = float(elements5[-1]["x"]) - float(elements5[-2]["x"])
378+
379+
# EAN-5 spacing should be roughly 2.5x EAN-2 (5 chars vs 2 chars)
380+
ratio = spacing5 / spacing2
381+
assert 2.0 < ratio < 3.0, (
382+
f"Spacing ratio should be ~2.5 for 5-digit vs 2-digit addon: {ratio:.2f}"
383+
)
384+
385+
@pytest.mark.parametrize(
386+
("code", "addon"),
387+
[
388+
("5901234123457", "12"),
389+
("5901234123457", "52495"),
390+
("4006381333931", "05"),
391+
("9780132354189", "51995"),
392+
],
393+
)
394+
def test_various_ean_addon_combinations(self, code: str, addon: str) -> None:
395+
"""Various EAN+addon combinations must follow GTIN layout
396+
rules."""
397+
svg = _render_svg(code, addon)
398+
texts = [unescape(t) for t in re.findall(r"<text[^>]*>(.*?)</text>", svg)]
399+
400+
# Always: 3 main blocks + addon + '>'
401+
assert len(texts) == 5
402+
assert texts[-2] == addon
403+
assert texts[-1] == ">"
404+
405+
# Verify vertical alignment
406+
elements = _extract_text_elements(svg)
407+
assert float(elements[-2]["y"]) == float(elements[-1]["y"])
408+
assert float(elements[-2]["y"]) < float(elements[0]["y"])
409+
410+
411+
class TestEAN8WithGuardbarAddon:
412+
"""Verify EAN-8 with guardbar and addon follows same layout
413+
rules."""
414+
415+
def test_ean8_guardbar_addon_text_order(self) -> None:
416+
"""EAN-8 with guardbar and addon: proper text order."""
417+
ean = EAN8("40267708", writer=SVGWriter(), guardbar=True, addon="12")
418+
out = BytesIO()
419+
ean.write(out, options={"write_text": True})
420+
svg = out.getvalue().decode("utf-8")
421+
422+
texts = [unescape(t) for t in re.findall(r"<text[^>]*>(.*?)</text>", svg)]
423+
424+
# EAN-8: first digit + 2 main blocks + addon + '>'
425+
assert len(texts) == 5
426+
assert texts[-2] == "12" # addon
427+
assert texts[-1] == ">" # marker

0 commit comments

Comments
 (0)