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.
Files changed (47) hide show
  1. quadra_core-0.1.0/LICENSE +21 -0
  2. quadra_core-0.1.0/PKG-INFO +77 -0
  3. quadra_core-0.1.0/README.md +56 -0
  4. quadra_core-0.1.0/pyproject.toml +115 -0
  5. quadra_core-0.1.0/pyproject.toml.orig +100 -0
  6. quadra_core-0.1.0/src/quadra_core/__init__.py +41 -0
  7. quadra_core-0.1.0/src/quadra_core/cli.py +88 -0
  8. quadra_core-0.1.0/src/quadra_core/parse.py +232 -0
  9. quadra_core-0.1.0/src/quadra_core/pipeline/__init__.py +0 -0
  10. quadra_core-0.1.0/src/quadra_core/pipeline/document.py +159 -0
  11. quadra_core-0.1.0/src/quadra_core/pipeline/extract.py +556 -0
  12. quadra_core-0.1.0/src/quadra_core/pipeline/lines.py +356 -0
  13. quadra_core-0.1.0/src/quadra_core/pipeline/money.py +206 -0
  14. quadra_core-0.1.0/src/quadra_core/pipeline/ocr.py +172 -0
  15. quadra_core-0.1.0/src/quadra_core/pipeline/preprocess.py +377 -0
  16. quadra_core-0.1.0/src/quadra_core/pipeline/recheck.py +234 -0
  17. quadra_core-0.1.0/src/quadra_core/pipeline/validate.py +207 -0
  18. quadra_core-0.1.0/src/quadra_core/profiles/__init__.py +0 -0
  19. quadra_core-0.1.0/src/quadra_core/profiles/esselunga.toml +68 -0
  20. quadra_core-0.1.0/src/quadra_core/profiles/loader.py +202 -0
  21. quadra_core-0.1.0/src/quadra_core/profiles/synthetic.toml +37 -0
  22. quadra_core-0.1.0/src/quadra_core/schema/receipt-1.0.0.schema.json +155 -0
  23. quadra_core-0.1.0/src/quadra_core/testdata/__init__.py +29 -0
  24. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_a.tsv +89 -0
  25. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_b.tsv +123 -0
  26. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_b_faded.tsv +125 -0
  27. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo.tsv +132 -0
  28. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_curled.tsv +342 -0
  29. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_modifier.tsv +133 -0
  30. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_no_header.tsv +134 -0
  31. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_payments.tsv +113 -0
  32. quadra_core-0.1.0/src/quadra_core/testdata/ocr/esselunga_photo_table.tsv +356 -0
  33. quadra_core-0.1.0/src/quadra_core/testdata/ocr/synthetic_clean.tsv +86 -0
  34. quadra_core-0.1.0/src/quadra_core/testdata/ocr/synthetic_faded.tsv +100 -0
  35. quadra_core-0.1.0/src/quadra_core/testdata/ocr/synthetic_unbalanced.tsv +86 -0
  36. quadra_core-0.1.0/tests/test_core_isolation.py +41 -0
  37. quadra_core-0.1.0/tests/test_esselunga.py +637 -0
  38. quadra_core-0.1.0/tests/test_lines.py +119 -0
  39. quadra_core-0.1.0/tests/test_money.py +84 -0
  40. quadra_core-0.1.0/tests/test_pipeline.py +253 -0
  41. quadra_core-0.1.0/tests/test_preprocess.py +136 -0
  42. quadra_core-0.1.0/tests/test_recheck.py +155 -0
  43. quadra_core-0.1.0/tests/test_schema.py +70 -0
  44. quadra_core-0.1.0/tools/calibrate.py +188 -0
  45. quadra_core-0.1.0/tools/make_fixtures.py +212 -0
  46. quadra_core-0.1.0/tools/scrub.py +118 -0
  47. 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())