|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import re |
| 4 | +from html import unescape |
| 5 | +from io import BytesIO |
| 6 | + |
3 | 7 | import pytest |
4 | 8 |
|
5 | 9 | from barcode import get_barcode |
|
11 | 15 | from barcode.isxn import ISBN13 |
12 | 16 | from barcode.isxn import ISSN |
13 | 17 | 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 | + |
14 | 44 |
|
15 | 45 |
|
16 | 46 | class TestEAN2Addon: |
@@ -262,3 +292,136 @@ def test_upca_addon2_parity_mod4(self) -> None: |
262 | 292 | code = upc.build()[0] |
263 | 293 | assert len(code) > 95 # Main UPC-A + addon |
264 | 294 |
|
| 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