diff --git a/services/ingestion/app/labels/__init__.py b/services/ingestion/app/labels/__init__.py new file mode 100644 index 000000000..8a74a43df --- /dev/null +++ b/services/ingestion/app/labels/__init__.py @@ -0,0 +1,19 @@ +"""GS1-128 shipping-label encoding (SEL-444).""" + +from .gs1_128 import ( + Gs1LabelError, + element_string, + encode_gs1_128, + human_readable, + to_pdf, + to_zpl, +) + +__all__ = [ + "Gs1LabelError", + "element_string", + "encode_gs1_128", + "human_readable", + "to_pdf", + "to_zpl", +] diff --git a/services/ingestion/app/labels/gs1_128.py b/services/ingestion/app/labels/gs1_128.py new file mode 100644 index 000000000..b505b5e8e --- /dev/null +++ b/services/ingestion/app/labels/gs1_128.py @@ -0,0 +1,485 @@ +"""GS1-128 label symbols as Code 128 codewords, ZPL, and a hand-built PDF. + +Fixed-length identifiers are checked with ``app.gs1_identifiers``. This module +does not invent identifier values and its errors do not repeat the caller's +value. No third-party barcode or PDF library. +""" + +from __future__ import annotations + +import calendar +from dataclasses import dataclass +from typing import Any + +from app.gs1_identifiers import validate_gln, validate_gtin, validate_sscc + +# Code 128 symbol values (ISO/IEC 15417). Not identifier data. +_START_C = 105 +_FNC1 = 102 +_CODE_C = 99 +_CODE_B = 100 +_CODE_A = 101 +_STOP = 106 + +_PAGE_W = 4 * 72 +_PAGE_H = 6 * 72 +_DEFAULT_WIDTH_DOTS = 812 # 4 inches at 203 dpi +_DEFAULT_HEIGHT_DOTS = 1218 # 6 inches at 203 dpi + +# GS1 AI encodable character set 82: digits, both letter cases, twenty +# specials, and no space. Figure 7.11-1 of the GS1 General Specifications. +_CSET82_TEXT = "!\"%&'()*+,-./0123456789:;<=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz" +if len(_CSET82_TEXT) != 82 or len(set(_CSET82_TEXT)) != 82: + raise RuntimeError("GS1 character set 82 is corrupt.") +_CSET82 = frozenset(_CSET82_TEXT) +_DIGITS = frozenset("0123456789") +_SEP = object() + +# Six bar/space widths for symbol values 0..106. The stop symbol (106) is +# followed by one extra 2-module bar when it is drawn. +_PATTERN_BLOB = """ +212222 222122 222221 121223 121322 131222 122213 122312 132212 221213 +221312 231212 112232 122132 122231 113222 123122 123221 223211 221132 +221231 213212 223112 312131 311222 321122 321221 312212 322112 322211 +212123 212321 232121 111323 131123 131321 112313 132113 132311 211313 +231113 231311 112133 112331 132131 113123 113321 133121 313121 211331 +231131 213113 213311 213131 311123 311321 331121 312113 312311 332111 +314111 221411 431111 111224 111422 121124 121421 141122 141221 112214 +112412 122114 122411 142112 142211 241211 221114 413111 241112 134111 +111242 121142 121241 114212 124112 124211 411212 421112 421211 212141 +214121 412121 111143 111341 131141 114113 114311 411113 411311 113141 +114131 311141 411131 211412 211214 211232 233111 +""" + + +class Gs1LabelError(ValueError): + """Label input that cannot be encoded. + + The message names the application identifier and the problem. It does not + include the submitted value. + """ + + +@dataclass(frozen=True) +class _Spec: + variable: bool + kind: str + size: int + + +# size is the exact length for fixed fields and the maximum for variable ones. +_SPECS: dict[str, _Spec] = { + "00": _Spec(False, "sscc", 18), + "01": _Spec(False, "gtin", 14), + "02": _Spec(False, "gtin", 14), + "11": _Spec(False, "date", 6), + "13": _Spec(False, "date", 6), + "15": _Spec(False, "date", 6), + "17": _Spec(False, "date", 6), + "410": _Spec(False, "gln", 13), + "414": _Spec(False, "gln", 13), + "10": _Spec(True, "text", 20), + "21": _Spec(True, "text", 20), + "37": _Spec(True, "count", 8), +} + +_VALIDATORS = { + "gtin": validate_gtin, + "gln": validate_gln, + "sscc": validate_sscc, +} + + +@dataclass(frozen=True) +class _Element: + ai: str + value: str + variable: bool + + +def _patterns() -> tuple[tuple[int, ...], ...]: + rows: list[tuple[int, ...]] = [] + for token in _PATTERN_BLOB.split(): + if len(token) != 6 or any(ch not in "1234" for ch in token): + raise RuntimeError("Code 128 pattern table is corrupt.") + widths = tuple(int(ch) for ch in token) + bars = widths[0] + widths[2] + widths[4] + spaces = widths[1] + widths[3] + widths[5] + if sum(widths) != 11 or bars not in (4, 6, 8) or spaces not in (3, 5, 7): + raise RuntimeError("Code 128 pattern table is corrupt.") + rows.append(widths) + if len(rows) != 107 or len(set(rows)) != 107: + raise RuntimeError("Code 128 pattern table is corrupt.") + anchors = { + 0: (2, 1, 2, 2, 2, 2), + 99: (1, 1, 3, 1, 4, 1), + 100: (1, 1, 4, 1, 3, 1), + 102: (4, 1, 1, 1, 3, 1), + 103: (2, 1, 1, 4, 1, 2), + 104: (2, 1, 1, 2, 1, 4), + 105: (2, 1, 1, 2, 3, 2), + 106: (2, 3, 3, 1, 1, 1), + } + for value, widths in anchors.items(): + if rows[value] != widths: + raise RuntimeError("Code 128 pattern table is corrupt.") + return tuple(rows) + + +_PATTERNS = _patterns() + + +def _pair(item: Any) -> tuple[Any, Any]: + if isinstance(item, dict): + if "ai" not in item or "value" not in item: + raise Gs1LabelError("Each label element needs an application identifier and a value.") + return item["ai"], item["value"] + if isinstance(item, (list, tuple)): + if len(item) != 2: + raise Gs1LabelError("Each label element needs an application identifier and a value.") + return item[0], item[1] + ai = getattr(item, "ai", None) + value = getattr(item, "value", None) + if ai is None and value is None: + raise Gs1LabelError("Each label element needs an application identifier and a value.") + return ai, value + + +def _canonical_ai(raw: Any) -> str: + if not isinstance(raw, str): + raise Gs1LabelError("Application identifier must be text.") + ai = raw.strip() + if not ai: + raise Gs1LabelError("Application identifier is empty.") + if ai in _SPECS: + return ai + if ai.isdigit() and 2 <= len(ai) <= 4: + raise Gs1LabelError(f"Application identifier {ai} is not supported.") + raise Gs1LabelError("Unknown application identifier.") + + +def _valid_date(value: str) -> bool: + if len(value) != 6 or not value.isdigit(): + return False + month = int(value[2:4]) + day = int(value[4:6]) + if month < 1 or month > 12: + return False + if day == 0: + return True + year = 2000 + int(value[0:2]) + return 1 <= day <= calendar.monthrange(year, month)[1] + + +def _clean_value(ai: str, raw: Any, spec: _Spec) -> str: + if spec.kind in _VALIDATORS: + result = _VALIDATORS[spec.kind](raw) + if not result.is_valid or result.value is None or len(result.value) != spec.size: + if result.is_valid and result.value is not None and len(result.value) != spec.size: + detail = f"This label needs {spec.size} digits." + else: + detail = result.error or "The value is not valid." + raise Gs1LabelError(f"Application identifier {ai} rejected. {detail}") + return result.value + + if not isinstance(raw, str): + raise Gs1LabelError(f"Application identifier {ai} value must be text.") + value = raw.strip() + if not value: + raise Gs1LabelError(f"Application identifier {ai} value is empty.") + if spec.kind == "date": + if not _valid_date(value): + raise Gs1LabelError(f"Application identifier {ai} needs a 6 digit date. Day 00 means the whole month.") + return value + if spec.kind == "count": + if not value.isdigit() or not 1 <= len(value) <= spec.size: + raise Gs1LabelError(f"Application identifier {ai} needs 1 to {spec.size} digits.") + return value + if not 1 <= len(value) <= spec.size or any(ch not in _CSET82 for ch in value): + raise Gs1LabelError(f"Application identifier {ai} has a character outside the GS1 set or the wrong length.") + return value + + +def _prepare(elements: Any) -> list[_Element]: + if elements is None or isinstance(elements, (str, bytes)): + raise Gs1LabelError("Label elements are empty.") + try: + items = list(elements) + except TypeError as exc: + raise Gs1LabelError("Label elements must be a list of application identifier and value pairs.") from exc + if not items: + raise Gs1LabelError("Label elements are empty.") + + fixed: list[_Element] = [] + variable: list[_Element] = [] + seen: set[str] = set() + for item in items: + ai_raw, value_raw = _pair(item) + ai = _canonical_ai(ai_raw) + if ai in seen: + raise Gs1LabelError(f"Application identifier {ai} is repeated.") + seen.add(ai) + spec = _SPECS[ai] + element = _Element(ai, _clean_value(ai, value_raw, spec), spec.variable) + if spec.variable: + variable.append(element) + else: + fixed.append(element) + return fixed + variable + + +def _atoms(elements: list[_Element]) -> list[Any]: + variable_count = sum(1 for element in elements if element.variable) + seen = 0 + atoms: list[Any] = [] + for element in elements: + atoms.extend(element.ai + element.value) + if element.variable: + seen += 1 + if seen < variable_count: + atoms.append(_SEP) + return atoms + + +def _join_atoms(atoms: list[Any]) -> str: + return "".join("\x1d" if atom is _SEP else atom for atom in atoms) + + +def _digit_run(atoms: list[Any], index: int) -> tuple[int, bool]: + cursor = index + while cursor < len(atoms) and isinstance(atoms[cursor], str) and atoms[cursor] in _DIGITS: + cursor += 1 + at_boundary = cursor >= len(atoms) or atoms[cursor] is _SEP + return cursor - index, at_boundary + + +def _switch_to_c(atoms: list[Any], index: int) -> bool: + """Code C pays for itself at 4 digits on a boundary, or 6 digits in the middle. + + An odd count waits one Code B digit so the switch lands on a pair. + """ + count, at_boundary = _digit_run(atoms, index) + needed = 4 if at_boundary else 6 + return count >= needed and count % 2 == 0 + + +def _encode_atoms(atoms: list[Any]) -> list[int]: + codewords = [_START_C, _FNC1] + mode = "C" + index = 0 + limit = len(atoms) + # Switches add symbols but must stay proportional to the data. A higher + # count means the subset machine stopped advancing. + ceiling = limit * 2 + 4 + while index < limit: + if len(codewords) > ceiling: + raise Gs1LabelError("Barcode could not be encoded.") + if mode == "C": + token = atoms[index] + if token is _SEP: + codewords.append(_FNC1) + index += 1 + continue + nxt = atoms[index + 1] if index + 1 < limit else None + if isinstance(token, str) and token in _DIGITS and isinstance(nxt, str) and nxt in _DIGITS: + codewords.append(int(token) * 10 + int(nxt)) + index += 2 + continue + codewords.append(_CODE_B) + mode = "B" + continue + if _switch_to_c(atoms, index): + codewords.append(_CODE_C) + mode = "C" + continue + token = atoms[index] + if token is _SEP: + codewords.append(_FNC1) + else: + codewords.append(ord(token) - 32) + index += 1 + + total = codewords[0] + for position, value in enumerate(codewords[1:], start=1): + total += position * value + codewords.append(total % 103) + codewords.append(_STOP) + return codewords + + +def _runs(codewords: list[int]) -> list[int]: + if not codewords or codewords[-1] != _STOP: + raise Gs1LabelError("Barcode is missing the stop symbol.") + runs: list[int] = [] + for codeword in codewords[:-1]: + runs.extend(_PATTERNS[codeword]) + runs.extend(_PATTERNS[_STOP]) + runs.append(2) + return runs + + +def _zpl_barcode(codewords: list[int]) -> str: + if codewords[0] != _START_C or codewords[1] != _FNC1 or codewords[-1] != _STOP: + raise Gs1LabelError("Barcode symbol is incomplete.") + pieces = [">;", ">8"] + mode = "C" + for codeword in codewords[2:-2]: + # Symbol 99 is the digit pair 99 while already in Code C. The same + # number latches to Code C only from another subset. Symbol 100 + # latches to Code B only from Code C. + if mode == "C" and codeword == _CODE_B: + pieces.append(">6") + mode = "B" + continue + if mode != "C" and codeword == _CODE_C: + pieces.append(">5") + mode = "C" + continue + if codeword == _CODE_A: + pieces.append(">7") + mode = "A" + continue + if codeword == _FNC1: + pieces.append(">8") + continue + if mode == "C": + if not 0 <= codeword <= 99: + raise Gs1LabelError("Barcode subset is not encodable in ZPL.") + pieces.append(f"{codeword:02d}") + continue + if mode == "B": + if not 0 <= codeword <= 95: + raise Gs1LabelError("Barcode subset is not encodable in ZPL.") + character = chr(codeword + 32) + # `>` is the ZPL invocation marker. `>0` is that character in subset B. + pieces.append(">0" if character == ">" else character) + continue + raise Gs1LabelError("Barcode subset is not encodable in ZPL.") + return "".join(pieces) + + +def _positive_dots(name: str, value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise Gs1LabelError(f"Label {name} must be a positive whole number.") + return value + + +def _pdf_number(value: float) -> str: + text = f"{value:.2f}" + return "0.00" if text == "-0.00" else text + + +def _pdf_stream(codewords: list[int], lines: list[str]) -> bytes: + runs = _runs(codewords) + quiet = 10 + margin = 14.0 + total = sum(runs) + 2 * quiet + module = min(1.8, (_PAGE_W - 2 * margin) / total) + barcode_width = total * module + x = (_PAGE_W - barcode_width) / 2 + quiet * module + bar_height = 110.0 + bar_y = _PAGE_H - 36.0 - bar_height + ops = ["q", "0 0 0 rg"] + bar = True + for width in runs: + drawn = width * module + if bar: + ops.append(f"{_pdf_number(x)} {_pdf_number(bar_y)} {_pdf_number(drawn)} {_pdf_number(bar_height)} re f") + x += drawn + bar = not bar + ops.append("Q") + ops.append("BT") + ops.append("/F1 10 Tf") + ops.append("0 0 0 rg") + text_y = bar_y - 18.0 + for index, line in enumerate(lines): + hex_text = line.encode("ascii").hex().upper() + if index == 0: + ops.append(f"1 0 0 1 {_pdf_number(margin)} {_pdf_number(text_y)} Tm") + else: + ops.append("0 -13 Td") + ops.append(f"<{hex_text}> Tj") + ops.append("ET") + return "\n".join(ops).encode("ascii") + + +def _pdf_document(stream: bytes) -> bytes: + objects = [ + b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", + b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", + ( + f"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] " + f"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n" + ).encode("ascii"), + ( + b"4 0 obj\n<< /Length " + + str(len(stream)).encode("ascii") + + b" >>\nstream\n" + + stream + + b"\nendstream\nendobj\n" + ), + b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n", + ] + header = b"%PDF-1.4\n" + chunks = [header] + offsets = [0] + cursor = len(header) + for obj in objects: + offsets.append(cursor) + chunks.append(obj) + cursor += len(obj) + xref_at = cursor + lines = [f"xref\n0 {len(offsets)}\n", "0000000000 65535 f \n"] + lines.extend(f"{offset:010d} 00000 n \n" for offset in offsets[1:]) + trailer = (f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF").encode("ascii") + return b"".join(chunks) + "".join(lines).encode("ascii") + trailer + + +def _lines(elements: list[_Element]) -> list[str]: + return [f"({element.ai}){element.value}" for element in elements] + + +def encode_gs1_128(elements: Any) -> list[int]: + """Code 128 codewords: Start C, FNC1, data, mod-103 check, stop (106).""" + return _encode_atoms(_atoms(_prepare(elements))) + + +def human_readable(elements: Any) -> str: + """Parenthesised HRI text, fixed-length application identifiers first.""" + return "".join(_lines(_prepare(elements))) + + +def element_string(elements: Any) -> str: + """Element string with a GS separator after every variable field but the last.""" + return _join_atoms(_atoms(_prepare(elements))) + + +def to_zpl(elements: Any, *, width_dots: int = _DEFAULT_WIDTH_DOTS, height_dots: int = _DEFAULT_HEIGHT_DOTS) -> str: + """ZPL for a 203 dpi label. The barcode starts ``>;>8`` and uses ``>8`` between variable fields.""" + prepared = _prepare(elements) + width = _positive_dots("width", width_dots) + height = _positive_dots("height", height_dots) + codewords = _encode_atoms(_atoms(prepared)) + modules = sum(_runs(codewords)) + 20 + usable = max(1, width - 80) + module = max(1, min(3, usable // modules)) + bar_height = max(60, min(height // 5, 320)) + commands = [ + "^XA", + f"^PW{width}", + f"^LL{height}", + f"^FO40,40^BY{module},2,{bar_height}^BCN,{bar_height},N,N,N,N^FD{_zpl_barcode(codewords)}^FS", + ] + text_y = 40 + bar_height + 28 + for line in _lines(prepared): + commands.append(f"^FO40,{text_y}^A0N,28,28^FD{line}^FS") + text_y += 36 + commands.append("^XZ") + return "\n".join(commands) + + +def to_pdf(elements: Any) -> bytes: + """A 4 by 6 inch PDF 1.4 page with filled bars and Helvetica HRI text.""" + prepared = _prepare(elements) + codewords = _encode_atoms(_atoms(prepared)) + return _pdf_document(_pdf_stream(codewords, _lines(prepared))) diff --git a/services/ingestion/tests/test_gs1_128_labels.py b/services/ingestion/tests/test_gs1_128_labels.py new file mode 100644 index 000000000..b3a5729d4 --- /dev/null +++ b/services/ingestion/tests/test_gs1_128_labels.py @@ -0,0 +1,403 @@ +"""SEL-444: GS1-128 label encoding. + +Identifiers are all zeros plus computed check digits. Dates come from +today's date and lot values from the length limits. No randomness. Assertions check structure. +""" + +from __future__ import annotations + +import calendar +import sys +from datetime import date +from pathlib import Path + +import pytest + +service_dir = Path(__file__).parent.parent +sys.path.insert(0, str(service_dir)) + +from app.gs1_identifiers import compute_gs1_check_digit # noqa: E402 +from app.labels.gs1_128 import ( # noqa: E402 + Gs1LabelError, + element_string, + encode_gs1_128, + human_readable, + to_pdf, + to_zpl, +) + +_FIXED = ("00", "01", "02", "11", "13", "15", "17", "410", "414") +_VARIABLE = ("10", "21", "37") +_ENTRYPOINTS = (encode_gs1_128, human_readable, element_string, to_pdf, to_zpl) + +# Code 128 symbol values. Independent of the encoder's private names. +_START_C = 105 +_FNC1 = 102 +_CODE_C = 99 +_CODE_B = 100 +_STOP = 106 + +# Dates are built from today's date. Nothing below is a fixed calendar date. +_TODAY = date.today() +_YY = _TODAY.strftime("%y") +_LEAP_YEAR = next(y for y in range(_TODAY.year, _TODAY.year + 8) if calendar.isleap(y)) +_NON_LEAP_YEAR = next(y for y in range(_TODAY.year, _TODAY.year + 8) if not calendar.isleap(y)) + + +def _yymmdd(year: int, month: int, day: int) -> str: + return f"{year % 100:02d}{month:02d}{day:02d}" + + +# Day 00 means the whole month and is allowed. +_PLAIN_DATE = _TODAY.strftime("%y%m%d") +_DAY_ZERO = _yymmdd(_TODAY.year, _TODAY.month, 0) +_LEAP_DAY = _yymmdd(_LEAP_YEAR, 2, calendar.monthrange(_LEAP_YEAR, 2)[1]) +# One day past the end of the month: Feb 29 in a non-leap year, and the +# day after the last day of this month. +_NON_LEAP_DAY = _yymmdd(_NON_LEAP_YEAR, 2, calendar.monthrange(_LEAP_YEAR, 2)[1]) +_PAST_MONTH_END = _yymmdd(_TODAY.year, _TODAY.month, calendar.monthrange(_TODAY.year, _TODAY.month)[1] + 1) +_MONTH_ZERO = _yymmdd(_TODAY.year, 0, 1) + +# Lot, serial and count values are built from the AI length limits. +_TEXT_MAX = 20 # AI 10 and AI 21 +_COUNT_MAX = 8 # AI 37 +_LOT = "0" +# ">" is ZPL's escape character. A serial ending in it forces a Code B latch +# (">6") and the escape (">0") in the ZPL field. +_ZPL_ESCAPE = ">" +_SERIAL_WITH_ESCAPE = "0" + _ZPL_ESCAPE +_NON_DIGIT = next(c for c in map(chr, range(33, 127)) if not c.isdigit()) + + +def _zero_identifier(body_length: int) -> str: + """All-zero body plus the mod-10 digit from compute_gs1_check_digit.""" + body = "0" * body_length + return body + str(compute_gs1_check_digit(body)) + + +def _bad_check(value: str) -> str: + """Change only the last digit, deterministically: (d + 1) mod 10.""" + updated = (int(value[-1]) + 1) % 10 + return value[:-1] + str(updated) + + +_GTIN14 = _zero_identifier(13) +_GTIN8 = _zero_identifier(7) +_SSCC = _zero_identifier(17) +_GLN = _zero_identifier(12) + +# Every supported AI. Input order is not fixed-then-variable, so the encoder +# still has to regroup. Serial ends in ">" so ZPL must latch to Code B (">6") +# and escape ">" as ">0". An all-zero label would stay in Code C and emit neither. +_ALL_AIS = [ + ("21", _SERIAL_WITH_ESCAPE), + ("17", _PLAIN_DATE), + ("10", _LOT), + ("01", _GTIN14), + ("00", _SSCC), + ("37", _LOT), + ("11", _DAY_ZERO), + ("02", _GTIN14), + ("13", _LEAP_DAY), + ("410", _GLN), + ("15", _PLAIN_DATE), + ("414", _GLN), +] + + +def _order(elements: list[tuple[str, str]]) -> list[tuple[str, str]]: + fixed = [element for element in elements if element[0] in _FIXED] + variable = [element for element in elements if element[0] in _VARIABLE] + return fixed + variable + + +def _expect_human(elements: list[tuple[str, str]]) -> str: + return "".join(f"({ai}){value}" for ai, value in _order(elements)) + + +def _expect_element(elements: list[tuple[str, str]]) -> str: + ordered = _order(elements) + variable = [element for element in ordered if element[0] in _VARIABLE] + chunks: list[str] = [] + seen = 0 + for ai, value in ordered: + chunks.append(ai + value) + if ai in _VARIABLE: + seen += 1 + if seen < len(variable): + chunks.append("\x1d") + return "".join(chunks) + + +def _checksum(symbols: list[int]) -> int: + total = symbols[0] + for position, value in enumerate(symbols[1:], start=1): + total += position * value + return total % 103 + + +def _decode(codewords: list[int]) -> str: + assert codewords[0] == _START_C + assert codewords[1] == _FNC1 + assert codewords[-1] == _STOP + mode = "C" + chars: list[str] = [] + for codeword in codewords[2:-2]: + # In Code C, symbol 99 is the digit pair 99. The same number latches + # to Code C only while the encoder is in Code B. + if mode == "C": + if codeword == _CODE_B: + mode = "B" + elif codeword == _FNC1: + chars.append("\x1d") + else: + assert 0 <= codeword <= 99 + chars.append(f"{codeword:02d}") + continue + if codeword == _CODE_C: + mode = "C" + elif codeword == _FNC1: + chars.append("\x1d") + else: + assert 0 <= codeword <= 95 + chars.append(chr(codeword + 32)) + return "".join(chars) + + +def _barcode_field(zpl: str) -> str: + marker = "^FD>;>8" + start = zpl.index(marker) + len("^FD") + return zpl[start : zpl.index("^FS", start)] + + +@pytest.mark.parametrize("fn", _ENTRYPOINTS) +@pytest.mark.parametrize("raw", [None, [], ()]) +def test_empty_input_raises(fn, raw): + with pytest.raises(Gs1LabelError): + fn(raw) + + +def test_error_is_a_value_error(): + assert issubclass(Gs1LabelError, ValueError) + + +@pytest.mark.parametrize("fn", _ENTRYPOINTS) +def test_unknown_ai_raises_without_echoing_the_value(fn): + value = _LOT + with pytest.raises(Gs1LabelError) as caught: + fn([("ZZ", value)]) + assert value not in str(caught.value) + assert "ZZ" not in str(caught.value) + + +@pytest.mark.parametrize("ai,body_length", [("01", 13), ("02", 13), ("00", 17), ("410", 12), ("414", 12)]) +def test_runtime_identifiers_pass_and_a_mutated_check_digit_raises(ai, body_length): + good = _zero_identifier(body_length) + assert int(good[-1]) == compute_gs1_check_digit(good[:-1]) + assert human_readable([(ai, good)]) == f"({ai}){good}" + bad = _bad_check(good) + assert bad != good + with pytest.raises(Gs1LabelError) as caught: + encode_gs1_128([(ai, bad)]) + message = str(caught.value) + assert "check digit" in message + assert bad not in message + assert good not in message + + +def test_day_zero_is_allowed_and_impossible_dates_raise(): + human_readable([("11", _DAY_ZERO), ("01", _GTIN14)]) + human_readable([("13", _LEAP_DAY)]) + human_readable([("17", _PLAIN_DATE)]) + with pytest.raises(Gs1LabelError): + human_readable([("13", _NON_LEAP_DAY)]) + with pytest.raises(Gs1LabelError): + human_readable([("15", _PAST_MONTH_END)]) + with pytest.raises(Gs1LabelError): + human_readable([("11", _MONTH_ZERO)]) + + +def test_variable_fields_accept_limits_and_reject_bad_shape(): + human_readable([("10", "0" * _TEXT_MAX), ("01", _GTIN14)]) + human_readable([("37", "0" * _COUNT_MAX), ("01", _GTIN14)]) + with pytest.raises(Gs1LabelError): + human_readable([("10", "0" * (_TEXT_MAX + 1)), ("01", _GTIN14)]) + with pytest.raises(Gs1LabelError): + human_readable([("10", " "), ("01", _GTIN14)]) + with pytest.raises(Gs1LabelError): + human_readable([("37", "0" + _NON_DIGIT), ("01", _GTIN14)]) + with pytest.raises(Gs1LabelError): + human_readable([("37", "0" * (_COUNT_MAX + 1)), ("01", _GTIN14)]) + with pytest.raises(Gs1LabelError): + human_readable([("21", ""), ("01", _GTIN14)]) + + +def test_charset_is_exactly_82_printable_characters(): + accepted = [] + for code in range(32, 127): + character = chr(code) + try: + human_readable([("10", character), ("01", _GTIN14)]) + except Gs1LabelError: + continue + accepted.append(character) + assert len(accepted) == 82 + assert " " not in accepted + assert all(character.isprintable() for character in accepted) + + +def test_ordering_puts_fixed_fields_first_and_gs_only_between_variable_fields(): + elements = [ + ("21", _LOT), + ("17", _PLAIN_DATE), + ("10", _LOT), + ("01", _GTIN14), + ("37", _LOT), + ] + assert human_readable(elements) == _expect_human(elements) + encoded = element_string(elements) + assert encoded == _expect_element(elements) + assert encoded.count("\x1d") == 2 + assert not encoded.startswith("\x1d") + assert not encoded.endswith("\x1d") + # Fixed fields keep input order (17 then 01), not numeric AI order. + readable = human_readable(elements) + assert readable.index("(17)") < readable.index("(01)") + assert readable.index("(01)") < readable.index("(21)") + assert readable.index("(21)") < readable.index("(10)") + assert readable.index("(10)") < readable.index("(37)") + fixed_only = [("17", elements[1][1]), ("01", elements[3][1])] + assert "\x1d" not in element_string(fixed_only) + + +def test_code128_round_trip_checksum_start_and_stop(): + elements = _ALL_AIS + codewords = encode_gs1_128(elements) + assert codewords[0] == _START_C + assert codewords[1] == _FNC1 + assert codewords[-1] == _STOP + assert _checksum(codewords[:-2]) == codewords[-2] + assert _decode(codewords) == element_string(elements) + assert not _decode(codewords).endswith("\x1d") + for ai, value in elements: + if ai in ("01", "02", "00", "410", "414"): + assert int(value[-1]) == compute_gs1_check_digit(value[:-1]) + assert value in _decode(codewords) + + +def test_short_gtin_whitespace_and_repeats(): + assert len(_GTIN8) == 8 + assert int(_GTIN8[-1]) == compute_gs1_check_digit(_GTIN8[:-1]) + with pytest.raises(Gs1LabelError) as caught: + encode_gs1_128([("01", _GTIN8)]) + assert _GTIN8 not in str(caught.value) + assert human_readable([("01", f" {_GTIN14}\n")]) == f"(01){_GTIN14}" + with pytest.raises(Gs1LabelError) as repeated: + human_readable([("01", _GTIN14), ("01", _GTIN14)]) + assert _GTIN14 not in str(repeated.value) + + +def test_all_digit_label_stays_in_code_c_with_one_leading_fnc1(): + elements = [("17", _PLAIN_DATE), ("01", _GTIN14)] + codewords = encode_gs1_128(elements) + assert codewords[0] == _START_C + assert codewords[1] == _FNC1 + assert _CODE_B not in codewords[2:-2] + assert _FNC1 not in codewords[2:-2] + barcode = _barcode_field(to_zpl(elements)) + assert barcode.startswith(">;>8") + assert barcode.count(">8") == 1 + assert ">6" not in barcode + + +def test_code_c_pair_matching_the_latch_value_stays_digits(): + """A Code C symbol equal to the latch value is still two digits in ZPL.""" + pair = "99" + assert _CODE_C == int(pair) + body = pair + "0" * 11 + gtin = body + str(compute_gs1_check_digit(body)) + assert len(gtin) == 14 + assert str(compute_gs1_check_digit(gtin[:-1])) == gtin[-1] + codewords = encode_gs1_128([("01", gtin)]) + assert _CODE_C in codewords[2:-2] + assert _CODE_B not in codewords[2:-2] + barcode = _barcode_field(to_zpl([("01", gtin)])) + assert barcode.startswith(f">;>801{pair}") + assert ">5" not in barcode + assert ">6" not in barcode + assert barcode.count(">8") == 1 + payload = barcode[len(">;>8") :] + assert payload.isdigit() + assert payload == "01" + gtin + + +def test_dict_elements_match_pairs(): + pairs = [("10", _LOT), ("01", _GTIN14)] + as_dicts = [{"ai": ai, "value": value} for ai, value in pairs] + assert human_readable(as_dicts) == human_readable(pairs) + assert encode_gs1_128(as_dicts) == encode_gs1_128(pairs) + + +def test_zpl_framing_and_fnc1_markers(): + elements = _ALL_AIS + zpl = to_zpl(elements) + assert zpl.startswith("^XA") + assert zpl.endswith("^XZ") + assert "^BC" in zpl + assert "^PW812" in zpl + assert "^LL1218" in zpl + barcode = _barcode_field(zpl) + assert barcode.startswith(">;>8") + variable_count = sum(1 for ai, _value in elements if ai in _VARIABLE) + # Leading FNC1 plus one separator between each variable field, and none after the last. + assert barcode.count(">8") == variable_count + # ">6" is the Code C to Code B latch. ">0" is subset B's encoding of ">". + # Both come from the serial ending in ">". All-zero digits would not produce them. + assert ">6" in barcode + assert ">0" in barcode + for ai, value in _order(elements): + assert f"^FD({ai}){value}^FS" in zpl + custom = to_zpl(elements[:1], width_dots=400, height_dots=600) + assert custom.startswith("^XA") + assert custom.endswith("^XZ") + assert "^PW400" in custom + assert "^LL600" in custom + assert _barcode_field(custom).startswith(">;>8") + + +def test_pdf_header_trailer_and_xref_offsets(): + elements = _ALL_AIS + pdf = to_pdf(elements) + assert pdf.startswith(b"%PDF-1.4") + assert pdf.endswith(b"%%EOF") + assert f"/MediaBox [0 0 {4 * 72} {6 * 72}]".encode("ascii") in pdf + assert b"/BaseFont /Helvetica" in pdf + assert b" re f\n" in pdf + for ai, value in _order(elements): + line = f"({ai}){value}".encode("ascii").hex().upper().encode("ascii") + assert line in pdf + + startxref = pdf.rfind(b"startxref") + pointer = pdf[startxref:].splitlines() + assert pointer[0] == b"startxref" + xref_at = int(pointer[1]) + assert pdf[xref_at : xref_at + 4] == b"xref" + section = pdf[xref_at:].splitlines() + start_at, count = section[1].split() + assert start_at == b"0" + count_i = int(count) + entries = section[2 : 2 + count_i] + assert len(entries) == count_i + assert entries[0] == b"0000000000 65535 f " + for index, entry in enumerate(entries[1:], start=1): + offset = int(entry.split()[0]) + header = f"{index} 0 obj".encode("ascii") + assert pdf[offset : offset + len(header)] == header + + length_at = pdf.index(b"/Length ") + length_end = pdf.index(b" ", length_at + len(b"/Length ")) + declared = int(pdf[length_at + len(b"/Length ") : length_end]) + stream_at = pdf.index(b"stream\n", length_at) + len(b"stream\n") + assert pdf[stream_at : stream_at + declared] + assert pdf[stream_at + declared :].startswith(b"\nendstream") + assert to_pdf(elements) == pdf