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 +21 -0
- sscc18-0.1.0/PKG-INFO +119 -0
- sscc18-0.1.0/README.md +91 -0
- sscc18-0.1.0/pyproject.toml +59 -0
- sscc18-0.1.0/src/sscc/__init__.py +70 -0
- sscc18-0.1.0/src/sscc/__main__.py +6 -0
- sscc18-0.1.0/src/sscc/cli.py +150 -0
- sscc18-0.1.0/src/sscc/core.py +419 -0
- sscc18-0.1.0/src/sscc/py.typed +0 -0
- sscc18-0.1.0/tests/test_core.py +202 -0
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,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)
|