sscc18 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sscc18-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Novacrest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sscc18-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: sscc18
3
+ Version: 0.1.0
4
+ Summary: Validate, repair, decode and generate GS1 SSCC-18 codes. Checks math and format, never registration.
5
+ Project-URL: Homepage, https://binlogic.io/free-tools/sscc-label-generator
6
+ Project-URL: Repository, https://github.com/novacrest-ai/sscc18
7
+ Project-URL: Documentation, https://github.com/novacrest-ai/sscc18#readme
8
+ Author-email: Novacrest <hello@novacrest.ai>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: barcode,check-digit,code128,gs1,gs1-128,logistics,pallet-label,sscc,sscc-18,supply-chain
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Manufacturing
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Office/Business
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+
29
+ # sscc
30
+
31
+ Validate, repair, decode and generate GS1 SSCC-18 codes. **Checks math and
32
+ format, never registration.**
33
+
34
+ Pure, dependency-free Python (3.9+) port of the `sscc-core` engine behind
35
+ Binlogic's free [SSCC Label Generator](https://binlogic.io/free-tools/sscc-label-generator).
36
+ Same deterministic GS1 mod-10 arithmetic, same result shapes, same
37
+ 23-case selftest — and the same honesty: a *valid* verdict means the
38
+ 18 digits and the check digit are mathematically right. It never means the
39
+ company prefix is licensed to anyone; no registry is consulted, and the
40
+ evidence receipt (schema `binlogic.sscc.v1`) says exactly what was checked,
41
+ what was inferred, and what stays unknown.
42
+
43
+ ## Install
44
+
45
+ ```sh
46
+ pip install sscc18
47
+ ```
48
+
49
+ ## Library
50
+
51
+ ```python
52
+ import sscc
53
+
54
+ # Validate — spaces, hyphens, a leading (00) or bare 00 are normalized.
55
+ v = sscc.validate("(00) 1 0614141 234567890 8")
56
+ v["valid"] # True
57
+ v["check"] # {'provided': 8, 'expected': 8, 'algorithm': 'GS1-mod-10'}
58
+ v["structure"]["extensionDigit"] # '1'
59
+
60
+ # Check digit for the first 17 digits.
61
+ sscc.check_digit("10614141234567890") # 8
62
+
63
+ # Repair a mistyped code: every single-digit fix and adjacent swap
64
+ # (or, for 17 digits, every insertion) that makes the mod-10 math pass.
65
+ r = sscc.repair("106141412345678907")
66
+ r["substitutions"][0]["sscc"] # a candidate that passes, e.g. '106141412345678908'
67
+
68
+ # Decode raw GS1-128 scan data (]C1 AIM prefix and (AI) notation handled).
69
+ d = sscc.decode("]C1(00)106141412345678908(21)9001")
70
+ d["sscc"] # '106141412345678908'
71
+ d["otherAIs"] # ['21'] — listed as present, not parsed
72
+
73
+ # Generate: company prefix 4-12 digits, serial fills to 17 digits + check
74
+ # digit; batches are capped at 500.
75
+ g = sscc.generate(prefix="0614141", ext="1", serial_start=234567890, count=1)
76
+ g["list"] # ['106141412345678908']
77
+
78
+ # Evidence receipt — machine-readable record of what was (not) checked.
79
+ rc = sscc.receipt(v)
80
+ rc["schema"] # 'binlogic.sscc.v1'
81
+ rc["unknown"] # includes: prefix licensing (GS1 registry not consulted),
82
+ # and where prefix ends / serial begins (not encoded)
83
+
84
+ # GS1-128 (Code 128 subset C, leading FNC1) — symbols and an SVG barcode.
85
+ sscc.code128c_symbols("106141412345678908") # [105, 102, ..., checksum, 106]
86
+ sscc.barcode_svg("106141412345678908")["svg"] # '<svg ...>'
87
+ sscc.hri("106141412345678908", 7) # '(00) 1 0614141 234567890 8'
88
+
89
+ sscc.selftest() # {'pass': 23, 'fail': 0, 'failures': []}
90
+ ```
91
+
92
+ Result dicts keep the engine's original key names (`serialWidth`,
93
+ `otherAIs`, `extensionDigit`, ...) so JSON output matches the web tool and
94
+ its upcoming API byte for byte.
95
+
96
+ ## CLI
97
+
98
+ All subcommands print JSON.
99
+
100
+ ```sh
101
+ sscc validate 106141412345678908 # exit 0 if valid, 1 if not
102
+ sscc validate 106141412345678907 --receipt # binlogic.sscc.v1 evidence receipt
103
+ sscc repair 10614141234567890 # 17 digits: every valid insertion
104
+ sscc decode "]C1(00)106141412345678908(21)9001"
105
+ sscc generate --prefix 0614141 --ext 0 --serial-start 1 --count 10 --hri
106
+ sscc selftest # the engine's 23 ported test cases
107
+ ```
108
+
109
+ ## What it will not tell you
110
+
111
+ Whether a company prefix is licensed, to whom, or where the prefix ends and
112
+ the serial reference begins — none of that is encoded in an SSCC, and this
113
+ package never guesses. Production SSCCs require a GS1 Company Prefix licensed
114
+ from GS1. The demo prefix `0614141` (GS1's documentation example) is for
115
+ testing and label-layout work only.
116
+
117
+ ## License
118
+
119
+ MIT © Novacrest
sscc18-0.1.0/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # sscc
2
+
3
+ Validate, repair, decode and generate GS1 SSCC-18 codes. **Checks math and
4
+ format, never registration.**
5
+
6
+ Pure, dependency-free Python (3.9+) port of the `sscc-core` engine behind
7
+ Binlogic's free [SSCC Label Generator](https://binlogic.io/free-tools/sscc-label-generator).
8
+ Same deterministic GS1 mod-10 arithmetic, same result shapes, same
9
+ 23-case selftest — and the same honesty: a *valid* verdict means the
10
+ 18 digits and the check digit are mathematically right. It never means the
11
+ company prefix is licensed to anyone; no registry is consulted, and the
12
+ evidence receipt (schema `binlogic.sscc.v1`) says exactly what was checked,
13
+ what was inferred, and what stays unknown.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ pip install sscc18
19
+ ```
20
+
21
+ ## Library
22
+
23
+ ```python
24
+ import sscc
25
+
26
+ # Validate — spaces, hyphens, a leading (00) or bare 00 are normalized.
27
+ v = sscc.validate("(00) 1 0614141 234567890 8")
28
+ v["valid"] # True
29
+ v["check"] # {'provided': 8, 'expected': 8, 'algorithm': 'GS1-mod-10'}
30
+ v["structure"]["extensionDigit"] # '1'
31
+
32
+ # Check digit for the first 17 digits.
33
+ sscc.check_digit("10614141234567890") # 8
34
+
35
+ # Repair a mistyped code: every single-digit fix and adjacent swap
36
+ # (or, for 17 digits, every insertion) that makes the mod-10 math pass.
37
+ r = sscc.repair("106141412345678907")
38
+ r["substitutions"][0]["sscc"] # a candidate that passes, e.g. '106141412345678908'
39
+
40
+ # Decode raw GS1-128 scan data (]C1 AIM prefix and (AI) notation handled).
41
+ d = sscc.decode("]C1(00)106141412345678908(21)9001")
42
+ d["sscc"] # '106141412345678908'
43
+ d["otherAIs"] # ['21'] — listed as present, not parsed
44
+
45
+ # Generate: company prefix 4-12 digits, serial fills to 17 digits + check
46
+ # digit; batches are capped at 500.
47
+ g = sscc.generate(prefix="0614141", ext="1", serial_start=234567890, count=1)
48
+ g["list"] # ['106141412345678908']
49
+
50
+ # Evidence receipt — machine-readable record of what was (not) checked.
51
+ rc = sscc.receipt(v)
52
+ rc["schema"] # 'binlogic.sscc.v1'
53
+ rc["unknown"] # includes: prefix licensing (GS1 registry not consulted),
54
+ # and where prefix ends / serial begins (not encoded)
55
+
56
+ # GS1-128 (Code 128 subset C, leading FNC1) — symbols and an SVG barcode.
57
+ sscc.code128c_symbols("106141412345678908") # [105, 102, ..., checksum, 106]
58
+ sscc.barcode_svg("106141412345678908")["svg"] # '<svg ...>'
59
+ sscc.hri("106141412345678908", 7) # '(00) 1 0614141 234567890 8'
60
+
61
+ sscc.selftest() # {'pass': 23, 'fail': 0, 'failures': []}
62
+ ```
63
+
64
+ Result dicts keep the engine's original key names (`serialWidth`,
65
+ `otherAIs`, `extensionDigit`, ...) so JSON output matches the web tool and
66
+ its upcoming API byte for byte.
67
+
68
+ ## CLI
69
+
70
+ All subcommands print JSON.
71
+
72
+ ```sh
73
+ sscc validate 106141412345678908 # exit 0 if valid, 1 if not
74
+ sscc validate 106141412345678907 --receipt # binlogic.sscc.v1 evidence receipt
75
+ sscc repair 10614141234567890 # 17 digits: every valid insertion
76
+ sscc decode "]C1(00)106141412345678908(21)9001"
77
+ sscc generate --prefix 0614141 --ext 0 --serial-start 1 --count 10 --hri
78
+ sscc selftest # the engine's 23 ported test cases
79
+ ```
80
+
81
+ ## What it will not tell you
82
+
83
+ Whether a company prefix is licensed, to whom, or where the prefix ends and
84
+ the serial reference begins — none of that is encoded in an SSCC, and this
85
+ package never guesses. Production SSCCs require a GS1 Company Prefix licensed
86
+ from GS1. The demo prefix `0614141` (GS1's documentation example) is for
87
+ testing and label-layout work only.
88
+
89
+ ## License
90
+
91
+ MIT © Novacrest
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sscc18"
7
+ version = "0.1.0"
8
+ description = "Validate, repair, decode and generate GS1 SSCC-18 codes. Checks math and format, never registration."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Novacrest", email = "hello@novacrest.ai" }]
13
+ requires-python = ">=3.9"
14
+ dependencies = []
15
+ keywords = [
16
+ "sscc",
17
+ "sscc-18",
18
+ "gs1",
19
+ "gs1-128",
20
+ "check-digit",
21
+ "barcode",
22
+ "code128",
23
+ "pallet-label",
24
+ "logistics",
25
+ "supply-chain",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 4 - Beta",
29
+ "Environment :: Console",
30
+ "Intended Audience :: Developers",
31
+ "Intended Audience :: Manufacturing",
32
+ "Operating System :: OS Independent",
33
+ "Programming Language :: Python :: 3",
34
+ "Programming Language :: Python :: 3.9",
35
+ "Programming Language :: Python :: 3.10",
36
+ "Programming Language :: Python :: 3.11",
37
+ "Programming Language :: Python :: 3.12",
38
+ "Programming Language :: Python :: 3.13",
39
+ "Topic :: Office/Business",
40
+ "Topic :: Software Development :: Libraries :: Python Modules",
41
+ "Typing :: Typed",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://binlogic.io/free-tools/sscc-label-generator"
46
+ Repository = "https://github.com/novacrest-ai/sscc18"
47
+ Documentation = "https://github.com/novacrest-ai/sscc18#readme"
48
+
49
+ [project.scripts]
50
+ sscc = "sscc.cli:main"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/sscc"]
54
+
55
+ [tool.hatch.build.targets.sdist]
56
+ include = ["src/sscc", "tests", "README.md", "LICENSE", "pyproject.toml"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
@@ -0,0 +1,70 @@
1
+ """sscc — validate, repair, decode and generate GS1 SSCC-18 codes.
2
+
3
+ A pure, dependency-free Python port of Binlogic's "sscc-core" TypeScript
4
+ engine (the one behind https://binlogic.io/free-tools/sscc-label-generator).
5
+
6
+ Everything is deterministic GS1 mod-10 math and format checking. Nothing here
7
+ consults the GS1 registry: a "valid" verdict means the arithmetic and
8
+ structure are right, never that the company prefix is licensed. The evidence
9
+ receipt (schema ``binlogic.sscc.v1``) states that explicitly.
10
+
11
+ Public API (TS name -> Python name):
12
+ clean -> clean
13
+ checkDigit -> check_digit
14
+ mathSteps -> math_steps
15
+ validate -> validate
16
+ repair -> repair
17
+ decode -> decode
18
+ generate -> generate (opts.serialStart -> serial_start)
19
+ receipt -> receipt
20
+ code128CSymbols -> code128c_symbols
21
+ barcodeSVG -> barcode_svg
22
+ hri -> hri
23
+ selftest -> selftest
24
+ VERSION -> ENGINE_VERSION (the ported engine's version, "1.0.0")
25
+
26
+ Result dictionaries keep the TS key names (camelCase where the TS uses it,
27
+ e.g. ``serialWidth``, ``otherAIs``, ``extensionDigit``) so JSON output is
28
+ byte-compatible with the web tool and the upcoming API.
29
+ """
30
+
31
+ from .core import (
32
+ C128,
33
+ ENGINE_NAME,
34
+ ENGINE_VERSION,
35
+ barcode_svg,
36
+ check_digit,
37
+ clean,
38
+ code128c_symbols,
39
+ decode,
40
+ generate,
41
+ hri,
42
+ math_steps,
43
+ receipt,
44
+ repair,
45
+ selftest,
46
+ strip_ai,
47
+ validate,
48
+ )
49
+
50
+ __version__ = "0.1.0"
51
+
52
+ __all__ = [
53
+ "C128",
54
+ "ENGINE_NAME",
55
+ "ENGINE_VERSION",
56
+ "__version__",
57
+ "barcode_svg",
58
+ "check_digit",
59
+ "clean",
60
+ "code128c_symbols",
61
+ "decode",
62
+ "generate",
63
+ "hri",
64
+ "math_steps",
65
+ "receipt",
66
+ "repair",
67
+ "selftest",
68
+ "strip_ai",
69
+ "validate",
70
+ ]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,150 @@
1
+ """sscc command-line interface.
2
+
3
+ Subcommands mirror the web tool: validate / repair / decode / generate
4
+ (plus the engine's own selftest). All output is JSON on stdout; engine
5
+ errors are JSON on stderr with exit code 2.
6
+
7
+ Exit codes: 0 = OK (validate: code is valid; decode: SSCC found and valid),
8
+ 1 = check failed, 2 = usage/engine error.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from datetime import datetime, timezone
17
+ from typing import Any, List, Optional
18
+
19
+ from . import __version__, core
20
+
21
+
22
+ def _emit(obj: Any) -> None:
23
+ # Matches the web tool's JSON.stringify(x, null, 2): 2-space indent,
24
+ # non-ASCII (the em-dashes in error messages) left unescaped.
25
+ print(json.dumps(obj, indent=2, ensure_ascii=False))
26
+
27
+
28
+ def _fail(message: str) -> int:
29
+ print(json.dumps({"error": message}, ensure_ascii=False), file=sys.stderr)
30
+ return 2
31
+
32
+
33
+ def _generated_at() -> str:
34
+ # JS new Date().toISOString(): millisecond precision, trailing Z.
35
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
36
+
37
+
38
+ def _cmd_validate(args: argparse.Namespace) -> int:
39
+ v = core.validate(args.code)
40
+ if args.receipt:
41
+ # Mirror the web page: when invalid and exactly one single-digit fix
42
+ # exists, record it as the inferred repair.
43
+ inferred: List[str] = []
44
+ if not v["valid"]:
45
+ r = core.repair(args.code)
46
+ if len(r["substitutions"]) == 1:
47
+ c = r["substitutions"][0]
48
+ inferred.append(
49
+ "Single-digit fix at position " + str(c["position"])
50
+ + " (" + c["from"] + " -> " + c["to"]
51
+ + ") is the offered repair; it is one of "
52
+ + str(len(r["substitutions"]) + len(r["transpositions"]))
53
+ + " single-edit candidates."
54
+ )
55
+ rc = core.receipt(v, {"inferred": inferred})
56
+ rc["generatedAt"] = _generated_at()
57
+ _emit(rc)
58
+ elif args.math:
59
+ out = dict(v)
60
+ out["math"] = core.math_steps(v["normalized"][:17]) if v["structure"] else None
61
+ _emit(out)
62
+ else:
63
+ _emit(v)
64
+ return 0 if v["valid"] else 1
65
+
66
+
67
+ def _cmd_repair(args: argparse.Namespace) -> int:
68
+ _emit(core.repair(args.code))
69
+ return 0
70
+
71
+
72
+ def _cmd_decode(args: argparse.Namespace) -> int:
73
+ d = core.decode(args.data)
74
+ _emit(d)
75
+ return 0 if (d["validation"] and d["validation"]["valid"]) else 1
76
+
77
+
78
+ def _cmd_generate(args: argparse.Namespace) -> int:
79
+ try:
80
+ g = core.generate(
81
+ prefix=args.prefix,
82
+ ext=args.ext,
83
+ serial_start=args.serial_start,
84
+ count=args.count,
85
+ )
86
+ except ValueError as e:
87
+ return _fail(str(e))
88
+ if args.hri:
89
+ g = dict(g)
90
+ g["hri"] = [core.hri(s, len(g["prefix"])) for s in g["list"]]
91
+ _emit(g)
92
+ return 0
93
+
94
+
95
+ def _cmd_selftest(args: argparse.Namespace) -> int:
96
+ st = core.selftest()
97
+ _emit(st)
98
+ return 0 if st["fail"] == 0 else 1
99
+
100
+
101
+ def build_parser() -> argparse.ArgumentParser:
102
+ p = argparse.ArgumentParser(
103
+ prog="sscc",
104
+ description=(
105
+ "Validate, repair, decode and generate GS1 SSCC-18 codes. "
106
+ "Checks math and format, never registration."
107
+ ),
108
+ )
109
+ p.add_argument(
110
+ "--version",
111
+ action="version",
112
+ version="sscc " + __version__ + " (engine " + core.ENGINE_NAME + " " + core.ENGINE_VERSION + ")",
113
+ )
114
+ sub = p.add_subparsers(dest="command", required=True)
115
+
116
+ pv = sub.add_parser("validate", help="check an SSCC-18 against the GS1 mod-10 math")
117
+ pv.add_argument("code", help="SSCC, with or without spaces/hyphens/(00)")
118
+ pv.add_argument("--receipt", action="store_true", help="emit the binlogic.sscc.v1 evidence receipt")
119
+ pv.add_argument("--math", action="store_true", help="include the per-digit mod-10 calculation")
120
+ pv.set_defaults(func=_cmd_validate)
121
+
122
+ pr = sub.add_parser("repair", help="single-edit and transposition fixes that make the math pass")
123
+ pr.add_argument("code", help="17- or 18-digit SSCC to repair")
124
+ pr.set_defaults(func=_cmd_repair)
125
+
126
+ pd = sub.add_parser("decode", help="extract and check the SSCC in GS1-128 scan data")
127
+ pd.add_argument("data", help="raw scan string, e.g. ']C1(00)106141412345678908(21)9001'")
128
+ pd.set_defaults(func=_cmd_decode)
129
+
130
+ pg = sub.add_parser("generate", help="mint SSCC-18 codes from a GS1 Company Prefix")
131
+ pg.add_argument("--prefix", required=True, help="GS1 Company Prefix (4-12 digits)")
132
+ pg.add_argument("--ext", default="0", help="extension digit 0-9 (default 0)")
133
+ pg.add_argument("--serial-start", default="0", help="first serial reference (default 0)")
134
+ pg.add_argument("--count", default="1", help="how many, 1-500 (default 1)")
135
+ pg.add_argument("--hri", action="store_true", help="include human-readable groupings")
136
+ pg.set_defaults(func=_cmd_generate)
137
+
138
+ ps = sub.add_parser("selftest", help="run the engine's 23-case deterministic selftest")
139
+ ps.set_defaults(func=_cmd_selftest)
140
+
141
+ return p
142
+
143
+
144
+ def main(argv: Optional[List[str]] = None) -> int:
145
+ args = build_parser().parse_args(argv)
146
+ return args.func(args)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ sys.exit(main())
@@ -0,0 +1,419 @@
1
+ """sscc.core — pure-Python port of the Binlogic "sscc-core" TypeScript engine (v1.0.0).
2
+
3
+ Ported verbatim from the dependency-free, DOM-free ``window.SSCC`` engine that
4
+ powers https://binlogic.io/free-tools/sscc-label-generator. Behavior, result
5
+ shapes (dict key names and order), error messages, and the
6
+ ``binlogic.sscc.v1`` evidence-receipt schema match the TypeScript source.
7
+
8
+ Everything here checks GS1 mod-10 *math and format only* — it never consults
9
+ the GS1 registry, so it can never tell you whether a company prefix is
10
+ licensed. The evidence receipt says so explicitly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any, Dict, List, Optional, Union
17
+
18
+ ENGINE_NAME = "sscc-core"
19
+ # Version of the TypeScript engine this module ports (receipts embed it, as
20
+ # the TS engine does). The *package* version lives in sscc.__version__.
21
+ ENGINE_VERSION = "1.0.0"
22
+
23
+ # JavaScript's \s character class (as used by the TS engine), spelled out so
24
+ # Python matches JS exactly (Python's \s omits U+FEFF, JS's omits U+001C-1F).
25
+ _JS_WS = "\t\n\x0b\x0c\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
26
+
27
+ # TS: /[\s\-‐-―]/g (whitespace, ASCII hyphen, U+2010..U+2015 dash family)
28
+ _CLEAN_RE = re.compile("[" + _JS_WS + "\\-\u2010-\u2015]")
29
+ _TRIM_RE = re.compile("^[" + _JS_WS + "]+|[" + _JS_WS + "]+$")
30
+ _WS_RE = re.compile("[" + _JS_WS + "]")
31
+ _AI00_RE = re.compile("\\(00\\)[" + _JS_WS + "]*([0-9]{18})")
32
+ _AI_RE = re.compile(r"\(([0-9]{2,4})\)")
33
+ _JS_INT_RE = re.compile("^[" + _JS_WS + "]*([+-]?[0-9]+)")
34
+
35
+
36
+ def _to_str(raw: Any) -> str:
37
+ """JS String(raw == null ? "" : raw)."""
38
+ return "" if raw is None else str(raw)
39
+
40
+
41
+ def _js_parse_int(value: Any) -> Optional[int]:
42
+ """JS parseInt(value, 10): leading whitespace/sign/digits; None plays NaN."""
43
+ m = _JS_INT_RE.match(_to_str(value))
44
+ return int(m.group(1)) if m else None
45
+
46
+
47
+ def _js_num(n: Union[int, float]) -> str:
48
+ """Format a number the way JS string-concatenation does (275, not 275.0)."""
49
+ if isinstance(n, float) and n.is_integer():
50
+ return str(int(n))
51
+ return str(n)
52
+
53
+
54
+ def clean(raw: Any) -> str:
55
+ """Strip whitespace and hyphen/dash characters (TS ``clean``)."""
56
+ return _CLEAN_RE.sub("", _to_str(raw))
57
+
58
+
59
+ def strip_ai(s: str) -> Dict[str, Optional[str]]:
60
+ """Strip a leading AI(00) in either "(00)" or bare "00" (20-digit) form."""
61
+ note: Optional[str] = None
62
+ if s.startswith("(00)"):
63
+ s = s[4:]
64
+ note = "Leading (00) Application Identifier removed."
65
+ elif re.fullmatch(r"00[0-9]{18}", s):
66
+ s = s[2:]
67
+ note = "Leading 00 read as the (00) Application Identifier."
68
+ return {"s": s, "note": note}
69
+
70
+
71
+ def check_digit(d17: str) -> int:
72
+ """GS1 mod-10 check digit for exactly 17 digits (TS ``checkDigit``)."""
73
+ if not isinstance(d17, str) or not re.fullmatch(r"[0-9]{17}", d17):
74
+ raise ValueError("checkDigit expects exactly 17 digits")
75
+ total = 0
76
+ for i in range(17):
77
+ digit = ord(d17[16 - i]) - 48 # from the right
78
+ total += digit * (3 if i % 2 == 0 else 1) # rightmost gets 3
79
+ return (10 - (total % 10)) % 10
80
+
81
+
82
+ def math_steps(d17: str) -> Dict[str, Any]:
83
+ """Per-position weights/products for the mod-10 sum (TS ``mathSteps``).
84
+
85
+ Precondition (as in TS): ``d17`` is a 17-digit string.
86
+ """
87
+ rows: List[Dict[str, int]] = []
88
+ total = 0
89
+ for i in range(17):
90
+ digit = ord(d17[i]) - 48
91
+ w = 3 if (16 - i) % 2 == 0 else 1
92
+ rows.append({"pos": i + 1, "digit": digit, "weight": w, "product": digit * w})
93
+ total += digit * w
94
+ return {"rows": rows, "sum": total, "check": (10 - (total % 10)) % 10}
95
+
96
+
97
+ def validate(raw: Any) -> Dict[str, Any]:
98
+ """Validate an SSCC-18; returns the TS ``validate`` result dict."""
99
+ c = clean(raw)
100
+ st = strip_ai(c)
101
+ s = st["s"] or ""
102
+ r: Dict[str, Any] = {
103
+ "input": str(raw),
104
+ "normalized": s,
105
+ "note": st["note"],
106
+ "valid": False,
107
+ "errors": [],
108
+ "length": len(s),
109
+ "structure": None,
110
+ "check": None,
111
+ }
112
+ if not re.fullmatch(r"[0-9]*", s):
113
+ r["errors"].append("Contains non-digit characters \u2014 an SSCC is digits only.")
114
+ return r
115
+ if len(s) != 18:
116
+ r["errors"].append("An SSCC has exactly 18 digits \u2014 this has " + str(len(s)) + ".")
117
+ return r
118
+ body = s[:17]
119
+ provided = ord(s[17]) - 48
120
+ expected = check_digit(body)
121
+ r["structure"] = {
122
+ "extensionDigit": s[0],
123
+ "companyPrefixAndSerial": s[1:17],
124
+ "checkDigit": s[17],
125
+ }
126
+ r["check"] = {"provided": provided, "expected": expected, "algorithm": "GS1-mod-10"}
127
+ if provided == expected:
128
+ r["valid"] = True
129
+ else:
130
+ r["errors"].append(
131
+ "Check digit is " + str(provided)
132
+ + " but the mod-10 math expects " + str(expected) + "."
133
+ )
134
+ return r
135
+
136
+
137
+ def repair(raw: Any) -> Dict[str, Any]:
138
+ """Single-edit repair search (TS ``repair``): substitutions, adjacent
139
+ transpositions (18-digit input) or insertions (17-digit input) that make
140
+ the GS1 mod-10 math pass."""
141
+ s = strip_ai(clean(raw))["s"] or ""
142
+ out: Dict[str, Any] = {
143
+ "substitutions": [],
144
+ "transpositions": [],
145
+ "insertions": [],
146
+ "kind": None,
147
+ }
148
+ if not re.fullmatch(r"[0-9]+", s):
149
+ return out
150
+ if len(s) == 18:
151
+ out["kind"] = "18-digit, failing"
152
+ for i in range(18):
153
+ for j in range(10):
154
+ ch = chr(48 + j)
155
+ if ch == s[i]:
156
+ continue
157
+ cand = s[:i] + ch + s[i + 1:]
158
+ if check_digit(cand[:17]) == ord(cand[17]) - 48:
159
+ out["substitutions"].append(
160
+ {"sscc": cand, "position": i + 1, "from": s[i], "to": ch}
161
+ )
162
+ for i in range(17):
163
+ if s[i] == s[i + 1]:
164
+ continue
165
+ cand = s[:i] + s[i + 1] + s[i] + s[i + 2:]
166
+ if check_digit(cand[:17]) == ord(cand[17]) - 48:
167
+ out["transpositions"].append({"sscc": cand, "positions": [i + 1, i + 2]})
168
+ elif len(s) == 17:
169
+ out["kind"] = "17-digit, one missing"
170
+ for i in range(18):
171
+ for j in range(10):
172
+ cand = s[:i] + chr(48 + j) + s[i:]
173
+ if check_digit(cand[:17]) == ord(cand[17]) - 48:
174
+ out["insertions"].append(
175
+ {"sscc": cand, "position": i + 1, "digit": str(j)}
176
+ )
177
+ return out
178
+
179
+
180
+ def decode(raw: Any) -> Dict[str, Any]:
181
+ """Decode GS1-128 scan data (TS ``decode``): handles a leading ``]C1`` AIM
182
+ prefix and parenthesized AIs; extracts and validates the SSCC under (00)."""
183
+ s = _TRIM_RE.sub("", _to_str(raw))
184
+ if s.startswith("]C1"):
185
+ s = s[3:]
186
+ out: Dict[str, Any] = {"sscc": None, "otherAIs": [], "validation": None, "error": None}
187
+ m = _AI00_RE.search(s)
188
+ if m:
189
+ out["sscc"] = m.group(1)
190
+ else:
191
+ m2 = re.match(r"00([0-9]{18})", _WS_RE.sub("", s))
192
+ if m2:
193
+ out["sscc"] = m2.group(1)
194
+ for g in _AI_RE.finditer(s):
195
+ if g.group(1) != "00":
196
+ out["otherAIs"].append(g.group(1))
197
+ if not out["sscc"]:
198
+ out["error"] = "No (00) Application Identifier with 18 digits found."
199
+ return out
200
+ out["validation"] = validate(out["sscc"])
201
+ return out
202
+
203
+
204
+ def generate(
205
+ prefix: Any,
206
+ ext: Any,
207
+ serial_start: Any = None,
208
+ count: Any = None,
209
+ ) -> Dict[str, Any]:
210
+ """Generate SSCC-18 codes (TS ``generate``; opts keys prefix/ext/
211
+ serialStart/count map to these arguments). Company prefix 4-12 digits;
212
+ serial fills to 17 digits + check digit; batch capped at 500."""
213
+ prefix_s = clean(prefix)
214
+ ext_s = str(ext) # TS String(opts.ext); None fails the single-digit test, as in TS
215
+ start = _js_parse_int(serial_start)
216
+ c = _js_parse_int(count)
217
+ count_n = max(1, min(500, c if c else 1))
218
+ if not re.fullmatch(r"[0-9]{4,12}", prefix_s):
219
+ raise ValueError("GS1 Company Prefix must be 4-12 digits.")
220
+ if not re.fullmatch(r"[0-9]", ext_s):
221
+ raise ValueError("Extension digit must be a single digit 0-9.")
222
+ if not (start is not None and start >= 0):
223
+ raise ValueError("First serial must be a non-negative number.")
224
+ serial_width = 16 - len(prefix_s)
225
+ max_serial = 10 ** serial_width - 1
226
+ out_list: List[str] = []
227
+ for k in range(count_n):
228
+ serial = start + k
229
+ if serial > max_serial:
230
+ raise ValueError(
231
+ "Serial " + str(serial) + " exceeds the " + str(serial_width)
232
+ + "-digit space this prefix leaves."
233
+ )
234
+ pad = str(serial)
235
+ while len(pad) < serial_width:
236
+ pad = "0" + pad
237
+ body = ext_s + prefix_s + pad
238
+ out_list.append(body + str(check_digit(body)))
239
+ return {"list": out_list, "serialWidth": serial_width, "prefix": prefix_s, "ext": ext_s}
240
+
241
+
242
+ def receipt(v: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
243
+ """Evidence receipt (schema ``binlogic.sscc.v1``) for a ``validate``
244
+ result — a machine-readable record of what was checked, what was
245
+ inferred, and what stays unknown (registration is never checked)."""
246
+ r: Dict[str, Any] = {
247
+ "schema": "binlogic.sscc.v1",
248
+ "engine": {"name": ENGINE_NAME, "version": ENGINE_VERSION, "algorithm": "GS1-mod-10"},
249
+ "input": v["input"],
250
+ "normalized": v["normalized"],
251
+ "valid": v["valid"],
252
+ "structure": v["structure"],
253
+ "check": v["check"],
254
+ "errors": v["errors"],
255
+ "known": [],
256
+ "inferred": [],
257
+ "unknown": [
258
+ "Whether the company prefix is licensed to the presenting party (GS1 registry not consulted).",
259
+ "Where the company prefix ends and the serial reference begins (not encoded in the SSCC).",
260
+ ],
261
+ }
262
+ if v["valid"]:
263
+ r["known"].append("18 digits observed; GS1 mod-10 check digit verified.")
264
+ elif v["structure"]:
265
+ r["known"].append("18 digits observed; check digit does not satisfy GS1 mod-10.")
266
+ else:
267
+ r["known"].append("Input does not parse as an 18-digit SSCC.")
268
+ if extra and extra.get("inferred"):
269
+ r["inferred"] = extra["inferred"]
270
+ return r
271
+
272
+
273
+ # ---------- GS1-128 (Code 128, subset C, leading FNC1) ----------
274
+ C128: List[str] = [
275
+ "212222", "222122", "222221", "121223", "121322", "131222", "122213", "122312", "132212", "221213",
276
+ "221312", "231212", "112232", "122132", "122231", "113222", "123122", "123221", "223211", "221132",
277
+ "221231", "213212", "223112", "312131", "311222", "321122", "321221", "312212", "322112", "322211",
278
+ "212123", "212321", "232121", "111323", "131123", "131321", "112313", "132113", "132311", "211313",
279
+ "231113", "231311", "112133", "112331", "132131", "113123", "113321", "133121", "313121", "211331",
280
+ "231131", "213113", "213311", "213131", "311123", "311321", "331121", "312113", "312311", "332111",
281
+ "314111", "221411", "431111", "111224", "111422", "121124", "121421", "141122", "141221", "112214",
282
+ "112412", "122114", "122411", "142112", "142211", "241211", "221114", "413111", "241112", "134111",
283
+ "111242", "121142", "121241", "114212", "124112", "124211", "411212", "421112", "421211", "212141",
284
+ "214121", "412121", "111143", "111341", "131141", "114113", "114311", "411113", "411311", "113141",
285
+ "114131", "311141", "411131", "211412", "211214", "211232", "2331112",
286
+ ]
287
+
288
+
289
+ def code128c_symbols(d18: str) -> List[int]:
290
+ """Code 128 symbol values for an SSCC-18: Start C, FNC1, nine digit
291
+ pairs, checksum, stop (TS ``code128CSymbols``)."""
292
+ syms = [105, 102] # Start C, FNC1
293
+ for i in range(0, 18, 2):
294
+ syms.append(int(d18[i:i + 2]))
295
+ total = 105
296
+ pos = 1
297
+ for k in range(1, len(syms)):
298
+ total += syms[k] * pos
299
+ pos += 1
300
+ syms.append(total % 103) # checksum
301
+ syms.append(106) # stop
302
+ return syms
303
+
304
+
305
+ def barcode_svg(
306
+ d18: str,
307
+ opts: Optional[Dict[str, Any]] = None,
308
+ ) -> Dict[str, Any]:
309
+ """Render the GS1-128 barcode as an SVG string (TS ``barcodeSVG``).
310
+ ``opts``: {"module": int (default 2), "height": int (default 90)}."""
311
+ opts = opts or {}
312
+ module = opts.get("module") or 2
313
+ height = opts.get("height") or 90
314
+ quiet = 10 * module
315
+ syms = code128c_symbols(d18)
316
+ x = quiet
317
+ rects: List[str] = []
318
+ for sym in syms:
319
+ pat = C128[sym]
320
+ for p in range(len(pat)):
321
+ w = (ord(pat[p]) - 48) * module
322
+ if p % 2 == 0:
323
+ rects.append(
324
+ '<rect x="' + _js_num(x) + '" y="0" width="' + _js_num(w)
325
+ + '" height="' + _js_num(height) + '"/>'
326
+ )
327
+ x += w
328
+ total = x + quiet
329
+ svg = (
330
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + _js_num(total)
331
+ + " " + _js_num(height + 26)
332
+ + '" role="img" aria-label="GS1-128 barcode for SSCC ' + d18
333
+ + '"><rect width="' + _js_num(total) + '" height="' + _js_num(height + 26)
334
+ + '" fill="#fff"/><g fill="#0f1b2d">' + "".join(rects)
335
+ + '</g><text x="' + _js_num(total / 2) + '" y="' + _js_num(height + 19)
336
+ + '" text-anchor="middle" font-family="monospace" font-size="14" fill="#0f1b2d">(00) '
337
+ + d18 + "</text></svg>"
338
+ )
339
+ return {"svg": svg, "width": total, "height": height + 26, "symbols": syms}
340
+
341
+
342
+ def hri(d18: str, prefix_len: Optional[int] = None) -> str:
343
+ """Human-readable interpretation with extension/prefix/serial/check
344
+ grouping when the prefix length is known (TS ``hri``)."""
345
+ if prefix_len and 4 <= prefix_len <= 12:
346
+ return (
347
+ "(00) " + d18[0] + " " + d18[1:1 + prefix_len] + " "
348
+ + d18[1 + prefix_len:17] + " " + d18[17]
349
+ )
350
+ return "(00) " + d18
351
+
352
+
353
+ # ---------- deterministic selftest ----------
354
+ def selftest() -> Dict[str, Any]:
355
+ """The TS engine's 23-case deterministic selftest, ported verbatim.
356
+ Returns {"pass": int, "fail": int, "failures": [names]}."""
357
+ state = {"pass": 0, "fail": 0}
358
+ msgs: List[str] = []
359
+
360
+ def ok(cond: bool, name: str) -> None:
361
+ if cond:
362
+ state["pass"] += 1
363
+ else:
364
+ state["fail"] += 1
365
+ msgs.append(name)
366
+
367
+ ok(check_digit("10614141234567890") == 8, "cd demo")
368
+ ok(validate("106141412345678908")["valid"] is True, "valid 18")
369
+ ok(validate("(00) 1 0614141 234567890 8")["valid"] is True, "valid with (00)+spaces")
370
+ ok(validate("00106141412345678908")["valid"] is True, "valid bare-00 20-digit")
371
+ ok(validate("106141412345678907")["valid"] is False, "bad check")
372
+ ok(len(validate("1061414123456789")["errors"]) > 0, "short")
373
+ ok(len(validate("10614141234567890X")["errors"]) > 0, "non-digit")
374
+ g = generate(prefix="0614141", ext="1", serial_start=234567890, count=1)
375
+ ok(g["list"][0] == "106141412345678908", "generate matches demo")
376
+ ok(g["serialWidth"] == 9, "serial width 7-digit prefix")
377
+ g2 = generate(prefix="0614141", ext="0", serial_start=1, count=3)
378
+ ok(len(g2["list"]) == 3 and len(g2["list"][0][:17]) == 17, "batch 3")
379
+ ok(all(validate(s)["valid"] for s in g2["list"]), "batch all valid")
380
+ rep = repair("106141412345678907")
381
+ ok(any(c["sscc"] == "106141412345678908" for c in rep["substitutions"]), "repair finds original")
382
+ rep17 = repair("10614141234567890")
383
+ ok(
384
+ len(rep17["insertions"]) > 0
385
+ and all(validate(c["sscc"])["valid"] for c in rep17["insertions"]),
386
+ "17-digit insertions valid",
387
+ )
388
+ swapped = "016141412345678908" # pos1-2 swap of valid demo
389
+ rep2 = repair(swapped)
390
+ ok(
391
+ any(c["sscc"] == "106141412345678908" for c in rep2["transpositions"])
392
+ or len(rep2["substitutions"]) > 0,
393
+ "transposition finds original",
394
+ )
395
+ d = decode("]C1(00)106141412345678908(21)12345")
396
+ ok(
397
+ d["sscc"] == "106141412345678908"
398
+ and d["validation"]["valid"]
399
+ and "21" in d["otherAIs"],
400
+ "decode with ]C1 + extra AI",
401
+ )
402
+ ok(decode("(10)ABC123")["error"] is not None, "decode without 00 errors")
403
+ syms = code128c_symbols("106141412345678908")
404
+ ok(syms[0] == 105 and syms[1] == 102 and syms[-1] == 106, "code128 frame")
405
+ ok(len(syms) == 13, "code128 symbol count")
406
+ chk = 0
407
+ for q in range(1, len(syms) - 2):
408
+ chk += syms[q] * q
409
+ chk = (105 + chk) % 103
410
+ ok(syms[-2] == chk, "code128 checksum self-consistent")
411
+ ok(len(C128) == 107, "pattern table 107 entries")
412
+ ok(barcode_svg("106141412345678908")["svg"].startswith("<svg"), "svg renders")
413
+ rc = receipt(validate("106141412345678908"))
414
+ ok(
415
+ rc["schema"] == "binlogic.sscc.v1" and rc["valid"] is True and len(rc["unknown"]) == 2,
416
+ "receipt shape",
417
+ )
418
+ ok(hri("106141412345678908", 7) == "(00) 1 0614141 234567890 8", "hri grouping")
419
+ return {"pass": state["pass"], "fail": state["fail"], "failures": msgs}
File without changes
@@ -0,0 +1,202 @@
1
+ """The TS engine's 23 selftest vectors, ported verbatim from
2
+ apps/binlogic_website/src/pages/free-tools/sscc-label-generator.astro
3
+ (sscc-core 1.0.0, function `selftest`), one pytest test per `ok(...)` case,
4
+ named after the TS case labels. Plus the ported `selftest()` itself.
5
+ """
6
+
7
+ import json
8
+
9
+ import pytest
10
+
11
+ import sscc
12
+ from sscc.cli import main as cli_main
13
+
14
+
15
+ # ---- vectors 1-7: check digit + validate ----
16
+
17
+ def test_01_cd_demo():
18
+ assert sscc.check_digit("10614141234567890") == 8
19
+
20
+
21
+ def test_02_valid_18():
22
+ assert sscc.validate("106141412345678908")["valid"] is True
23
+
24
+
25
+ def test_03_valid_with_00_and_spaces():
26
+ assert sscc.validate("(00) 1 0614141 234567890 8")["valid"] is True
27
+
28
+
29
+ def test_04_valid_bare_00_20_digit():
30
+ assert sscc.validate("00106141412345678908")["valid"] is True
31
+
32
+
33
+ def test_05_bad_check():
34
+ assert sscc.validate("106141412345678907")["valid"] is False
35
+
36
+
37
+ def test_06_short():
38
+ assert len(sscc.validate("1061414123456789")["errors"]) > 0
39
+
40
+
41
+ def test_07_non_digit():
42
+ assert len(sscc.validate("10614141234567890X")["errors"]) > 0
43
+
44
+
45
+ # ---- vectors 8-11: generate ----
46
+
47
+ def test_08_generate_matches_demo():
48
+ g = sscc.generate(prefix="0614141", ext="1", serial_start=234567890, count=1)
49
+ assert g["list"][0] == "106141412345678908"
50
+
51
+
52
+ def test_09_serial_width_7_digit_prefix():
53
+ g = sscc.generate(prefix="0614141", ext="1", serial_start=234567890, count=1)
54
+ assert g["serialWidth"] == 9
55
+
56
+
57
+ def test_10_batch_3():
58
+ g2 = sscc.generate(prefix="0614141", ext="0", serial_start=1, count=3)
59
+ assert len(g2["list"]) == 3 and len(g2["list"][0][:17]) == 17
60
+
61
+
62
+ def test_11_batch_all_valid():
63
+ g2 = sscc.generate(prefix="0614141", ext="0", serial_start=1, count=3)
64
+ assert all(sscc.validate(s)["valid"] for s in g2["list"])
65
+
66
+
67
+ # ---- vectors 12-14: repair ----
68
+
69
+ def test_12_repair_finds_original():
70
+ rep = sscc.repair("106141412345678907")
71
+ assert any(c["sscc"] == "106141412345678908" for c in rep["substitutions"])
72
+
73
+
74
+ def test_13_17_digit_insertions_valid():
75
+ rep17 = sscc.repair("10614141234567890")
76
+ assert len(rep17["insertions"]) > 0
77
+ assert all(sscc.validate(c["sscc"])["valid"] for c in rep17["insertions"])
78
+
79
+
80
+ def test_14_transposition_finds_original():
81
+ swapped = "016141412345678908" # pos1-2 swap of valid demo
82
+ rep2 = sscc.repair(swapped)
83
+ assert (
84
+ any(c["sscc"] == "106141412345678908" for c in rep2["transpositions"])
85
+ or len(rep2["substitutions"]) > 0
86
+ )
87
+
88
+
89
+ # ---- vectors 15-16: decode ----
90
+
91
+ def test_15_decode_with_c1_and_extra_ai():
92
+ d = sscc.decode("]C1(00)106141412345678908(21)12345")
93
+ assert d["sscc"] == "106141412345678908"
94
+ assert d["validation"]["valid"]
95
+ assert "21" in d["otherAIs"]
96
+
97
+
98
+ def test_16_decode_without_00_errors():
99
+ assert sscc.decode("(10)ABC123")["error"] is not None
100
+
101
+
102
+ # ---- vectors 17-21: Code 128 / SVG ----
103
+
104
+ def test_17_code128_frame():
105
+ syms = sscc.code128c_symbols("106141412345678908")
106
+ assert syms[0] == 105 and syms[1] == 102 and syms[-1] == 106
107
+
108
+
109
+ def test_18_code128_symbol_count():
110
+ assert len(sscc.code128c_symbols("106141412345678908")) == 13
111
+
112
+
113
+ def test_19_code128_checksum_self_consistent():
114
+ syms = sscc.code128c_symbols("106141412345678908")
115
+ chk = 0
116
+ for q in range(1, len(syms) - 2):
117
+ chk += syms[q] * q
118
+ chk = (105 + chk) % 103
119
+ assert syms[-2] == chk
120
+
121
+
122
+ def test_20_pattern_table_107_entries():
123
+ assert len(sscc.C128) == 107
124
+
125
+
126
+ def test_21_svg_renders():
127
+ assert sscc.barcode_svg("106141412345678908")["svg"].startswith("<svg")
128
+
129
+
130
+ # ---- vectors 22-23: receipt + HRI ----
131
+
132
+ def test_22_receipt_shape():
133
+ rc = sscc.receipt(sscc.validate("106141412345678908"))
134
+ assert rc["schema"] == "binlogic.sscc.v1"
135
+ assert rc["valid"] is True
136
+ assert len(rc["unknown"]) == 2
137
+
138
+
139
+ def test_23_hri_grouping():
140
+ assert sscc.hri("106141412345678908", 7) == "(00) 1 0614141 234567890 8"
141
+
142
+
143
+ # ---- the ported selftest itself must report 23/0, like the web console ----
144
+
145
+ def test_selftest_23_of_23():
146
+ st = sscc.selftest()
147
+ assert st == {"pass": 23, "fail": 0, "failures": []}
148
+
149
+
150
+ # ---- CLI smoke (JSON out, exit codes) ----
151
+
152
+ def test_cli_validate_valid_exit_0(capsys):
153
+ assert cli_main(["validate", "106141412345678908"]) == 0
154
+ out = json.loads(capsys.readouterr().out)
155
+ assert out["valid"] is True and out["check"]["provided"] == 8
156
+
157
+
158
+ def test_cli_validate_invalid_exit_1_and_receipt(capsys):
159
+ assert cli_main(["validate", "106141412345678907", "--receipt"]) == 1
160
+ rc = json.loads(capsys.readouterr().out)
161
+ assert rc["schema"] == "binlogic.sscc.v1"
162
+ assert rc["valid"] is False
163
+ assert "generatedAt" in rc
164
+ assert any("registry not consulted" in u for u in rc["unknown"])
165
+
166
+
167
+ def test_cli_generate_and_decode_roundtrip(capsys):
168
+ assert cli_main(["generate", "--prefix", "0614141", "--ext", "1",
169
+ "--serial-start", "234567890", "--count", "1"]) == 0
170
+ g = json.loads(capsys.readouterr().out)
171
+ assert g["list"] == ["106141412345678908"]
172
+ assert cli_main(["decode", "(00)" + g["list"][0]]) == 0
173
+ d = json.loads(capsys.readouterr().out)
174
+ assert d["validation"]["valid"] is True
175
+
176
+
177
+ def test_cli_generate_bad_prefix_exit_2(capsys):
178
+ assert cli_main(["generate", "--prefix", "123", "--ext", "0",
179
+ "--serial-start", "1"]) == 2
180
+ err = json.loads(capsys.readouterr().err)
181
+ assert err["error"] == "GS1 Company Prefix must be 4-12 digits."
182
+
183
+
184
+ # ---- pinned engine error messages (TS parity) ----
185
+
186
+ def test_error_messages_match_ts():
187
+ with pytest.raises(ValueError, match="checkDigit expects exactly 17 digits"):
188
+ sscc.check_digit("123")
189
+ v = sscc.validate("1061414123456789")
190
+ assert v["errors"] == ["An SSCC has exactly 18 digits — this has 16."]
191
+ v2 = sscc.validate("10614141234567890X")
192
+ assert v2["errors"] == ["Contains non-digit characters — an SSCC is digits only."]
193
+ v3 = sscc.validate("106141412345678907")
194
+ assert v3["errors"] == ["Check digit is 7 but the mod-10 math expects 8."]
195
+ assert sscc.validate("(00)106141412345678908")["note"] == (
196
+ "Leading (00) Application Identifier removed."
197
+ )
198
+ assert sscc.validate("00106141412345678908")["note"] == (
199
+ "Leading 00 read as the (00) Application Identifier."
200
+ )
201
+ with pytest.raises(ValueError, match="Serial 10000 exceeds the 4-digit space"):
202
+ sscc.generate(prefix="061414112345", ext="0", serial_start=9999, count=2)