quadra-core 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.
- quadra_core-0.1.0/LICENSE +21 -0
- quadra_core-0.1.0/PKG-INFO +77 -0
- quadra_core-0.1.0/README.md +56 -0
- quadra_core-0.1.0/pyproject.toml +115 -0
- quadra_core-0.1.0/pyproject.toml.orig +100 -0
- quadra_core-0.1.0/src/quadra_core/__init__.py +41 -0
- quadra_core-0.1.0/src/quadra_core/cli.py +88 -0
- quadra_core-0.1.0/src/quadra_core/parse.py +232 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/__init__.py +0 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/document.py +159 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/extract.py +556 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/lines.py +356 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/money.py +206 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/ocr.py +172 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/preprocess.py +377 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/recheck.py +234 -0
- quadra_core-0.1.0/src/quadra_core/pipeline/validate.py +207 -0
- quadra_core-0.1.0/src/quadra_core/profiles/__init__.py +0 -0
- quadra_core-0.1.0/src/quadra_core/profiles/esselunga.toml +68 -0
- quadra_core-0.1.0/src/quadra_core/profiles/loader.py +202 -0
- quadra_core-0.1.0/src/quadra_core/profiles/synthetic.toml +37 -0
- quadra_core-0.1.0/src/quadra_core/schema/receipt-1.0.0.schema.json +155 -0
- quadra_core-0.1.0/src/quadra_core/testdata/__init__.py +29 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_a.tsv +89 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_b.tsv +123 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_b_faded.tsv +125 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo.tsv +132 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_curled.tsv +342 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_modifier.tsv +133 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_no_header.tsv +134 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_payments.tsv +113 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_table.tsv +356 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/synthetic_clean.tsv +86 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/synthetic_faded.tsv +100 -0
- quadra_core-0.1.0/src/quadra_core/testdata/ocr/synthetic_unbalanced.tsv +86 -0
- quadra_core-0.1.0/tests/test_core_isolation.py +41 -0
- quadra_core-0.1.0/tests/test_esselunga.py +637 -0
- quadra_core-0.1.0/tests/test_lines.py +119 -0
- quadra_core-0.1.0/tests/test_money.py +84 -0
- quadra_core-0.1.0/tests/test_pipeline.py +253 -0
- quadra_core-0.1.0/tests/test_preprocess.py +136 -0
- quadra_core-0.1.0/tests/test_recheck.py +155 -0
- quadra_core-0.1.0/tests/test_schema.py +70 -0
- quadra_core-0.1.0/tools/calibrate.py +188 -0
- quadra_core-0.1.0/tools/make_fixtures.py +212 -0
- quadra_core-0.1.0/tools/scrub.py +118 -0
- quadra_core-0.1.0/tools/try_photo.py +229 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stefano Mazzoleni
|
|
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.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quadra-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic, LLM-free parser for supermarket till receipts.
|
|
5
|
+
Keywords: receipt,ocr,tesseract,parser,supermarket,budgeting
|
|
6
|
+
Author: Stefano Mazzoleni
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: pillow>=11
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Project-URL: Homepage, https://github.com/SteMazzO/quadra-core
|
|
18
|
+
Project-URL: Repository, https://github.com/SteMazzO/quadra-core
|
|
19
|
+
Project-URL: Issues, https://github.com/SteMazzO/quadra-core/issues
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Quadra core
|
|
23
|
+
|
|
24
|
+
Reads a supermarket till receipt from a photo. No ML model, no network calls: Tesseract does the OCR, then geometry and arithmetic do the rest.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from quadra_core import parse_image
|
|
29
|
+
|
|
30
|
+
document, status = parse_image(Path("receipt.jpg"), profile_id="esselunga")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`status` is `ok`, `partial` or `failed`. It never raises just because a receipt was hard to read: you get a document back either way, with whatever went wrong listed in `validation.warnings`.
|
|
34
|
+
|
|
35
|
+
pip install quadra-core
|
|
36
|
+
sudo apt install tesseract-ocr tesseract-ocr-ita # for images
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
The line totals plus any discounts have to equal the printed total, so the parser can check its own work.
|
|
41
|
+
|
|
42
|
+
When the sums don't match it crops out the price column and OCRs that on its own, which gives a second independent reading of every price. Where the two readings disagree it keeps both and picks the combination that hits the printed total. That also recovers lines whose price was unreadable the first time. If two different combinations both add up it changes nothing and flags the receipt.
|
|
43
|
+
|
|
44
|
+
## Adding a shop
|
|
45
|
+
|
|
46
|
+
Everything shop-specific is a TOML profile in `src/quadra_core/profiles/`. The code knows how to find a price column, the profile says what a price looks like.
|
|
47
|
+
|
|
48
|
+
Start with a stub, since `calibrate.py` needs a profile to report against:
|
|
49
|
+
|
|
50
|
+
```toml
|
|
51
|
+
# src/quadra_core/profiles/myshop.toml
|
|
52
|
+
[profile]
|
|
53
|
+
id = "myshop"
|
|
54
|
+
version = 1
|
|
55
|
+
calibrated = false
|
|
56
|
+
|
|
57
|
+
[format]
|
|
58
|
+
money = '\d{1,3},\d{2}'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then point it at a photo:
|
|
62
|
+
|
|
63
|
+
python tools/calibrate.py receipt.jpg --profile myshop
|
|
64
|
+
|
|
65
|
+
It prints what Tesseract read, where the price column landed, which rule matched each line, and whether the totals add up. Add `[[rules]]` and `[regions]` anchors until the lines stop coming back as `unknown`. Rules are tried in order and the first match wins, so put the specific ones first.
|
|
66
|
+
|
|
67
|
+
Add `[fingerprint]` anchors so the shop is recognised without `--profile`. A VAT number is more stable than a branch address. Save the OCR as a fixture with `--save-fixture NAME` so later changes have something to regress against. Set `calibrated = true` once it balances, otherwise every parse carries a `profile_not_calibrated` warning.
|
|
68
|
+
|
|
69
|
+
Before committing a fixture, check it for personal data: the address, the loyalty card number, the points balance. `tools/scrub.py` covers the Esselunga ones.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
uv run pytest
|
|
74
|
+
|
|
75
|
+
## Licence
|
|
76
|
+
|
|
77
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Quadra core
|
|
2
|
+
|
|
3
|
+
Reads a supermarket till receipt from a photo. No ML model, no network calls: Tesseract does the OCR, then geometry and arithmetic do the rest.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from quadra_core import parse_image
|
|
8
|
+
|
|
9
|
+
document, status = parse_image(Path("receipt.jpg"), profile_id="esselunga")
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`status` is `ok`, `partial` or `failed`. It never raises just because a receipt was hard to read: you get a document back either way, with whatever went wrong listed in `validation.warnings`.
|
|
13
|
+
|
|
14
|
+
pip install quadra-core
|
|
15
|
+
sudo apt install tesseract-ocr tesseract-ocr-ita # for images
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
The line totals plus any discounts have to equal the printed total, so the parser can check its own work.
|
|
20
|
+
|
|
21
|
+
When the sums don't match it crops out the price column and OCRs that on its own, which gives a second independent reading of every price. Where the two readings disagree it keeps both and picks the combination that hits the printed total. That also recovers lines whose price was unreadable the first time. If two different combinations both add up it changes nothing and flags the receipt.
|
|
22
|
+
|
|
23
|
+
## Adding a shop
|
|
24
|
+
|
|
25
|
+
Everything shop-specific is a TOML profile in `src/quadra_core/profiles/`. The code knows how to find a price column, the profile says what a price looks like.
|
|
26
|
+
|
|
27
|
+
Start with a stub, since `calibrate.py` needs a profile to report against:
|
|
28
|
+
|
|
29
|
+
```toml
|
|
30
|
+
# src/quadra_core/profiles/myshop.toml
|
|
31
|
+
[profile]
|
|
32
|
+
id = "myshop"
|
|
33
|
+
version = 1
|
|
34
|
+
calibrated = false
|
|
35
|
+
|
|
36
|
+
[format]
|
|
37
|
+
money = '\d{1,3},\d{2}'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then point it at a photo:
|
|
41
|
+
|
|
42
|
+
python tools/calibrate.py receipt.jpg --profile myshop
|
|
43
|
+
|
|
44
|
+
It prints what Tesseract read, where the price column landed, which rule matched each line, and whether the totals add up. Add `[[rules]]` and `[regions]` anchors until the lines stop coming back as `unknown`. Rules are tried in order and the first match wins, so put the specific ones first.
|
|
45
|
+
|
|
46
|
+
Add `[fingerprint]` anchors so the shop is recognised without `--profile`. A VAT number is more stable than a branch address. Save the OCR as a fixture with `--save-fixture NAME` so later changes have something to regress against. Set `calibrated = true` once it balances, otherwise every parse carries a `profile_not_calibrated` warning.
|
|
47
|
+
|
|
48
|
+
Before committing a fixture, check it for personal data: the address, the loyalty card number, the points balance. `tools/scrub.py` covers the Esselunga ones.
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
uv run pytest
|
|
53
|
+
|
|
54
|
+
## Licence
|
|
55
|
+
|
|
56
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quadra-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Deterministic, LLM-free parser for supermarket till receipts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = [
|
|
14
|
+
"receipt",
|
|
15
|
+
"ocr",
|
|
16
|
+
"tesseract",
|
|
17
|
+
"parser",
|
|
18
|
+
"supermarket",
|
|
19
|
+
"budgeting",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: POSIX :: Linux",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["Pillow>=11"]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "Stefano Mazzoleni"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/SteMazzO/quadra-core"
|
|
36
|
+
Repository = "https://github.com/SteMazzO/quadra-core"
|
|
37
|
+
Issues = "https://github.com/SteMazzO/quadra-core/issues"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
quadra-core = "quadra_core.cli:main"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pre-commit",
|
|
47
|
+
"ruff",
|
|
48
|
+
{ include-group = "test" },
|
|
49
|
+
]
|
|
50
|
+
test = [
|
|
51
|
+
"pytest",
|
|
52
|
+
"pytest-cov",
|
|
53
|
+
"jsonschema",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.uv.build-backend]
|
|
57
|
+
source-include = [
|
|
58
|
+
"tests/**",
|
|
59
|
+
"tools/**",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
pythonpath = ["src"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
target-version = "py312"
|
|
68
|
+
line-length = 88
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = [
|
|
72
|
+
"F",
|
|
73
|
+
"E",
|
|
74
|
+
"W",
|
|
75
|
+
"I",
|
|
76
|
+
"D",
|
|
77
|
+
"N",
|
|
78
|
+
"UP",
|
|
79
|
+
"PL",
|
|
80
|
+
"RUF",
|
|
81
|
+
"ERA",
|
|
82
|
+
"PERF",
|
|
83
|
+
"B",
|
|
84
|
+
"G",
|
|
85
|
+
"Q",
|
|
86
|
+
"C4",
|
|
87
|
+
"YTT",
|
|
88
|
+
"T20",
|
|
89
|
+
"SIM",
|
|
90
|
+
"PIE",
|
|
91
|
+
"FIX",
|
|
92
|
+
"ICN",
|
|
93
|
+
"LOG",
|
|
94
|
+
"PYI",
|
|
95
|
+
"RET",
|
|
96
|
+
]
|
|
97
|
+
ignore = [
|
|
98
|
+
"B008",
|
|
99
|
+
"D104",
|
|
100
|
+
"D203",
|
|
101
|
+
"D205",
|
|
102
|
+
"D213",
|
|
103
|
+
"E741",
|
|
104
|
+
"PLR0912",
|
|
105
|
+
"PLR0913",
|
|
106
|
+
"PLR2004",
|
|
107
|
+
"T201",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[tool.ruff.lint.per-file-ignores]
|
|
111
|
+
"tests/**/*" = [
|
|
112
|
+
"S101",
|
|
113
|
+
"D",
|
|
114
|
+
"T20",
|
|
115
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quadra-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Deterministic, LLM-free parser for supermarket till receipts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Stefano Mazzoleni" }]
|
|
14
|
+
keywords = ["receipt", "ocr", "tesseract", "parser", "supermarket", "budgeting"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: POSIX :: Linux",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"Pillow>=11",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/SteMazzO/quadra-core"
|
|
29
|
+
Repository = "https://github.com/SteMazzO/quadra-core"
|
|
30
|
+
Issues = "https://github.com/SteMazzO/quadra-core/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pre-commit",
|
|
36
|
+
"ruff",
|
|
37
|
+
{include-group = "test"},
|
|
38
|
+
]
|
|
39
|
+
test = [
|
|
40
|
+
"pytest",
|
|
41
|
+
"pytest-cov",
|
|
42
|
+
"jsonschema",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
quadra-core = "quadra_core.cli:main"
|
|
47
|
+
|
|
48
|
+
[tool.uv.build-backend]
|
|
49
|
+
source-include = ["tests/**", "tools/**"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
pythonpath = ["src"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
target-version = "py312"
|
|
57
|
+
line-length = 88
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = [
|
|
61
|
+
"F",
|
|
62
|
+
"E",
|
|
63
|
+
"W",
|
|
64
|
+
"I",
|
|
65
|
+
"D",
|
|
66
|
+
"N",
|
|
67
|
+
"UP",
|
|
68
|
+
"PL",
|
|
69
|
+
"RUF",
|
|
70
|
+
"ERA",
|
|
71
|
+
"PERF",
|
|
72
|
+
"B",
|
|
73
|
+
"G",
|
|
74
|
+
"Q",
|
|
75
|
+
"C4",
|
|
76
|
+
"YTT",
|
|
77
|
+
"T20",
|
|
78
|
+
"SIM",
|
|
79
|
+
"PIE",
|
|
80
|
+
"FIX",
|
|
81
|
+
"ICN",
|
|
82
|
+
"LOG",
|
|
83
|
+
"PYI",
|
|
84
|
+
"RET",
|
|
85
|
+
]
|
|
86
|
+
ignore = [
|
|
87
|
+
"B008", # function-call-in-default-argument
|
|
88
|
+
"D104", # undocumented-public-package
|
|
89
|
+
"D203", # incorrect-blank-line-before-class
|
|
90
|
+
"D205", # missing-blank-line-after-summary
|
|
91
|
+
"D213", # multi-line-summary-second-line
|
|
92
|
+
"E741", # ambiguous-variable-name
|
|
93
|
+
"PLR0912", # too-many-branches
|
|
94
|
+
"PLR0913", # too-many-arguments
|
|
95
|
+
"PLR2004", # magic-value-comparison
|
|
96
|
+
"T201", # print
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
[tool.ruff.lint.per-file-ignores]
|
|
100
|
+
"tests/**/*" = ["S101", "D", "T20"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Read a supermarket till receipt from a photo.
|
|
2
|
+
|
|
3
|
+
Tesseract does the OCR, then geometry and arithmetic do the rest. The same
|
|
4
|
+
receipt always parses the same way, and when it gets one wrong you can see why.
|
|
5
|
+
|
|
6
|
+
from quadra_core import parse_image, parse_tsv
|
|
7
|
+
|
|
8
|
+
document, status = parse_image(Path("receipt.jpg"), profile_id="esselunga")
|
|
9
|
+
|
|
10
|
+
`status` is "ok", "partial" or "failed". It never raises just because a receipt
|
|
11
|
+
was hard to read: you get a document back either way, with the problems listed
|
|
12
|
+
in `validation.warnings`.
|
|
13
|
+
|
|
14
|
+
Everything shop-specific lives in a TOML profile rather than in this code, so
|
|
15
|
+
adding a shop means writing a profile.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from quadra_core.parse import parse_image, parse_tsv, read_input
|
|
23
|
+
from quadra_core.pipeline.document import no_review
|
|
24
|
+
from quadra_core.profiles.loader import Profile, ProfileError, available, load, select
|
|
25
|
+
|
|
26
|
+
# The JSON Schema every document validates against. Shipped with the package so
|
|
27
|
+
# callers can check output without keeping their own copy in sync.
|
|
28
|
+
SCHEMA_PATH = Path(__file__).parent / "schema" / "receipt-1.0.0.schema.json"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SCHEMA_PATH",
|
|
32
|
+
"Profile",
|
|
33
|
+
"ProfileError",
|
|
34
|
+
"available",
|
|
35
|
+
"load",
|
|
36
|
+
"no_review",
|
|
37
|
+
"parse_image",
|
|
38
|
+
"parse_tsv",
|
|
39
|
+
"read_input",
|
|
40
|
+
"select",
|
|
41
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""`quadra-core` on the command line: read a receipt, print the document.
|
|
2
|
+
|
|
3
|
+
Small on purpose. It is here so you can try the library and check a profile
|
|
4
|
+
against a photo without writing a script. Storing, reviewing and exporting
|
|
5
|
+
receipts are jobs for whatever gets built on top.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from quadra_core.parse import IMAGE_SUFFIXES, parse_image, parse_tsv, read_input
|
|
16
|
+
from quadra_core.profiles import loader
|
|
17
|
+
|
|
18
|
+
# Exit code carries the result, so a shell script or worker does not have to
|
|
19
|
+
# parse stdout.
|
|
20
|
+
EXIT_OK, EXIT_PARTIAL, EXIT_FAILED, EXIT_ERROR = 0, 1, 2, 3
|
|
21
|
+
|
|
22
|
+
_STATUS_EXIT = {"ok": EXIT_OK, "partial": EXIT_PARTIAL, "failed": EXIT_FAILED}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run_parse(args: argparse.Namespace) -> int:
|
|
26
|
+
source: Path | None = args.path
|
|
27
|
+
if source is not None and source.suffix.lower() in IMAGE_SUFFIXES:
|
|
28
|
+
document, status = parse_image(
|
|
29
|
+
source, profile_id=args.profile, printed_total=args.total
|
|
30
|
+
)
|
|
31
|
+
# Only of use to something archiving the originals.
|
|
32
|
+
document.pop("_tsv", None)
|
|
33
|
+
document.pop("_prepared", None)
|
|
34
|
+
else:
|
|
35
|
+
tsv, ocr_meta = read_input(source)
|
|
36
|
+
document, status = parse_tsv(
|
|
37
|
+
tsv, profile_id=args.profile, ocr_meta=ocr_meta, printed_total=args.total
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
json.dump(document, sys.stdout, indent=2, ensure_ascii=False)
|
|
41
|
+
sys.stdout.write("\n")
|
|
42
|
+
return _STATUS_EXIT.get(status, EXIT_ERROR)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_profiles(_args: argparse.Namespace) -> int:
|
|
46
|
+
for profile in loader.available():
|
|
47
|
+
state = "calibrated" if profile.calibrated else "UNCALIBRATED"
|
|
48
|
+
print(f"{profile.id:<12} v{profile.version} {state}")
|
|
49
|
+
return EXIT_OK
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
"""Assemble the command line."""
|
|
54
|
+
ap = argparse.ArgumentParser(prog="quadra-core", description=__doc__)
|
|
55
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
parse = sub.add_parser("parse", help="read a receipt image or Tesseract TSV")
|
|
58
|
+
parse.add_argument(
|
|
59
|
+
"path",
|
|
60
|
+
nargs="?",
|
|
61
|
+
type=Path,
|
|
62
|
+
help="image or .tsv file; omit to read TSV from stdin",
|
|
63
|
+
)
|
|
64
|
+
parse.add_argument("--profile", help="force a profile instead of fingerprinting")
|
|
65
|
+
parse.add_argument(
|
|
66
|
+
"--total",
|
|
67
|
+
type=int,
|
|
68
|
+
help="printed total in minor units, when the receipt's own is unreadable",
|
|
69
|
+
)
|
|
70
|
+
parse.set_defaults(handler=_run_parse)
|
|
71
|
+
|
|
72
|
+
profiles = sub.add_parser("profiles", help="list the shop profiles available")
|
|
73
|
+
profiles.set_defaults(handler=_run_profiles)
|
|
74
|
+
return ap
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: list[str] | None = None) -> int:
|
|
78
|
+
"""Entry point. Returns the exit code rather than raising on a bad receipt."""
|
|
79
|
+
args = build_parser().parse_args(argv)
|
|
80
|
+
try:
|
|
81
|
+
return args.handler(args)
|
|
82
|
+
except (loader.ProfileError, OSError) as exc:
|
|
83
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
84
|
+
return EXIT_ERROR
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|