tidyscan 0.3.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.
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 (2026-11-06)
4
+
5
+ First public release.
6
+
7
+ - **Upright**: Tesseract's orientation detection, used only with enough
8
+ confidence and an allowed script; an unsure upside-down page, or a page
9
+ that lies sideways, is settled by reading it both ways round.
10
+ - **Straight**: skew up to 45 degrees, from coarse projection candidates and
11
+ Tesseract's text-line baselines, checked by measuring again; at most 5
12
+ minutes of Tesseract per page.
13
+ - **Lossless PDF**: JPEG pages as they are, 1-bit pages Flate-compressed;
14
+ turns as `/Rotate`, skew as a drawing matrix.
15
+ - **OCR** through OCRmyPDF: Tesseract by default, PaddleOCR PP-OCRv5 on
16
+ OpenVINO (CPU) for receipts, photo captions and tables; the OCRmyPDF plugin
17
+ `tidyscan.ocrmypdf_paddle` works with OCRmyPDF 16 and 17. PDF/A-2b on
18
+ request.
19
+ - `tidyscan models fetch`: the PaddleOCR models from this project's release,
20
+ checked by sha256.
21
+ - `tidyscan watch IN OUT`: document folders (as scankeyd writes them) into
22
+ PDFs, as a service, with settings from the environment and a status file
23
+ for a monitor.
24
+ - Container images: `base` (pages to PDF, standard library), the default
25
+ `full` (Tesseract and OCRmyPDF) and, on request, `paddle`.
tidyscan-0.3.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Luuk Visser
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: tidyscan
3
+ Version: 0.3.0
4
+ Summary: Auto-rotate and deskew scanned pages into a lossless PDF, with an optional OCR text layer.
5
+ Keywords: scan,deskew,orientation,pdf,ocr,tesseract,paddleocr
6
+ Author: Luuk Visser
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-Expression: BSD-3-Clause
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
14
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
15
+ License-File: LICENSE
16
+ Requires-Dist: Pillow>=10 ; extra == "ocr"
17
+ Requires-Dist: ocrmypdf>=16 ; extra == "ocr"
18
+ Requires-Dist: Pillow>=10 ; extra == "paddle"
19
+ Requires-Dist: ocrmypdf>=16,<18 ; extra == "paddle"
20
+ Requires-Dist: rapidocr>=3.10,<4 ; extra == "paddle"
21
+ Requires-Dist: openvino>=2025.1 ; extra == "paddle"
22
+ Requires-Dist: Pillow>=10 ; extra == "straighten"
23
+ Project-URL: Changelog, https://github.com/luukvisser/tidyscan/blob/main/CHANGELOG.md
24
+ Project-URL: Issues, https://github.com/luukvisser/tidyscan/issues
25
+ Project-URL: Source, https://github.com/luukvisser/tidyscan
26
+ Provides-Extra: ocr
27
+ Provides-Extra: paddle
28
+ Provides-Extra: straighten
29
+
30
+ # tidyscan
31
+
32
+ Scanned pages turned upright and straightened into a PDF, losslessly, and
33
+ optionally made searchable.
34
+
35
+ - **Upright**: Tesseract's orientation detection decides the quarter turn,
36
+ only when it is sure and the script is one you allow (Latin by default); an
37
+ unsure upside-down page is settled by reading it both ways round.
38
+ - **Straight**: a page laid crooked on the glass (up to 45 degrees) is
39
+ measured on its text lines and checked by measuring again.
40
+ - **Lossless**: JPEG pages go into the PDF byte for byte, 1-bit pages
41
+ Flate-compressed; the turn is the page's `/Rotate`, the skew a drawing
42
+ matrix. Nothing is resampled. Standard library only.
43
+ - **Searchable** (optional): a text layer by OCRmyPDF, read by Tesseract, or by
44
+ PaddleOCR (PP-OCRv5 on OpenVINO, CPU) for receipts.
45
+
46
+ When anything is unsure, the page is kept as scanned. Written for the scans of
47
+ [scankeyd](https://github.com/luukvisser/scankeyd) (the Scan key of Brother
48
+ network printers to a folder), and usable for any scanned pages.
49
+
50
+ ## Install
51
+
52
+ pip install tidyscan # pages to PDF only
53
+ pip install 'tidyscan[straighten]' # plus Pillow: turning upright and straightening
54
+ pip install 'tidyscan[ocr]' # plus OCRmyPDF: the text layer
55
+ pip install 'tidyscan[paddle]' # plus RapidOCR and OpenVINO: PaddleOCR
56
+
57
+ Python 3.11 or newer, on Linux. Turning and straightening also need the
58
+ `tesseract` program with its `osd` data, OCR its language data (Debian:
59
+ `tesseract-ocr tesseract-ocr-osd tesseract-ocr-nld ...`). PaddleOCR needs its
60
+ models once (checked by sha256, never downloaded by themselves):
61
+
62
+ tidyscan models fetch # into ~/.local/share/tidyscan/models
63
+ tidyscan models path # where they are looked up, and whether they are there
64
+
65
+ ## Use
66
+
67
+ tidyscan --ocr eng+nld -o out.pdf page1.jpg page2.pbm
68
+ tidyscan --ocr nld --engine paddle -o receipt.pdf receipt.jpg
69
+
70
+ JPEG and PBM pages go in as they are. Other formats (PNG, TIFF, every page of
71
+ a multi-page TIFF) are read with Pillow and converted first, to PBM when
72
+ black-and-white, else to JPEG (quality 95): for those the PDF holds the
73
+ conversion. Options: `--dpi` (300), `--no-rotate`, `--no-deskew`,
74
+ `--min-confidence` (3), `--engine`, `-q`. Exit code 0, or 1 for a page that is
75
+ not a JPEG or PBM; an OCR failure writes the PDF without text and says so.
76
+
77
+ ```python
78
+ import tidyscan
79
+ from tidyscan import ocr, pdf
80
+
81
+ pages = [open("page1.jpg", "rb").read()]
82
+ turns, skews = zip(*(tidyscan.straighten(p, 300) for p in pages))
83
+ data = pdf.build(pages, 300, list(turns), list(skews))
84
+ data = ocr.add_text_layer(data, "eng+nld") # raises ocr.OcrError; engine="tesseract" (default), "paddle", "auto"
85
+ ```
86
+
87
+ A page is JPEG bytes, binary PBM (P4) bytes, or an object with `to_pbm()`.
88
+ `straighten()` never raises and logs to the `tidyscan` logger; `turn=` takes a
89
+ turn already known (from the scanner), `budget=` the seconds of Tesseract it
90
+ may spend on a page (300; what is not measured by then is kept as scanned).
91
+
92
+ ### Which OCR engine
93
+
94
+ | engine | words found | words right | 1 page | each further page | memory |
95
+ |---|---|---|---|---|---|
96
+ | **Tesseract 5.5** (`--engine tesseract`, the default) | 90% | 87% | 3.6 s | 2.7 s | 0.15 GB |
97
+ | PaddleOCR PP-OCRv5 (`--engine paddle`) | 83% | 92% | 6.1 s | 9.9 s | 0.9 GB |
98
+
99
+ Tesseract reads letters, documents, forms and book pages better, faster and
100
+ in less memory. PaddleOCR reads receipts, photo captions and tables better
101
+ (it adds far fewer wrong words on tables), and phone photos of receipts that
102
+ Tesseract cannot read at all. `--engine auto` takes PaddleOCR when it is
103
+ installed and the languages are Latin-script ones, else Tesseract; Tesseract
104
+ also takes over a document PaddleOCR fails on. PaddleOCR's Latin recogniser
105
+ reads Dutch, English and some 30 other languages at once. Measured on 45 pages
106
+ in 188 scan-like variants, through the whole pipeline, on 2 cores of an Intel
107
+ N150: [docs/benchmarks.md](docs/benchmarks.md).
108
+
109
+ ## As a service: `tidyscan watch`
110
+
111
+ `tidyscan watch IN OUT` turns document folders into PDFs, oldest first: a
112
+ folder `IN/<function>/<name>/` with the pages (`page-0001.jpg`,
113
+ `page-0002.pbm`, ...) and a `document.json` (optional `"resolution"`, default
114
+ 300; `"pages"`, default the page files in order; `"ocr": true` for a text
115
+ layer), as [scankeyd](https://github.com/luukvisser/scankeyd) writes them.
116
+ Each becomes `OUT/<name>.pdf`, written as a hidden `.part` file and renamed;
117
+ then the folder goes. A document that cannot be read moves to `IN/.failed/`;
118
+ when OCR fails, the PDF is written without text. IN and its function folders
119
+ must be writable for this user, as each folder goes once its PDF is there.
120
+
121
+ | Option | Environment | Default | |
122
+ |---|---|---|---|
123
+ | `--ocr LANGS` | `OCR_LANGUAGES` | `eng` | text layer languages, e.g. `eng+nld` |
124
+ | `--engine` | `OCR_ENGINE` | `tesseract` | `tesseract`, `paddle` or `auto` |
125
+ | `--ocr-folders` | `OCR_FOLDERS` | `ocr` | for documents without an `"ocr"` field: the function folders that get a text layer (comma list, `all`, or empty for none) |
126
+ | `--no-rotate`, `--no-deskew` | `ROTATE`, `DESKEW` | `auto` | `on`, `off` or `auto` (on when Tesseract is there) |
127
+ | `--pdfa` | `PDFA` | `off` | PDF/A-2b for the documents with a text layer (still lossless: 1-bit pages become CCITT G4) |
128
+ | `--status FILE` | `STATUS_FILE` | none | a JSON status file for a monitor: `when`, `last_ok`, `last_file`, `last_error`, `failed`, `waiting`, and the counts `pdfs`, `unreadable`, `no_text` |
129
+ | `--keep-folders` | | off | `OUT/<function>/<name>.pdf` |
130
+ | `--interval` | | 5 | seconds between looks at IN |
131
+ | `--once` | | | convert what is there, then stop |
132
+
133
+ On/off settings take `on`/`off`, `true`/`false`, `yes`/`no` or `1`/`0`; any
134
+ other value stops the start with a message.
135
+
136
+ **Docker**: three images, built from this repository (`docker build ...`):
137
+ `--target base` (pages to PDF only, ~77 MB, no copyleft software), the default
138
+ `full` (with Tesseract and OCRmyPDF, ~480 MB) and `--target paddle` (with
139
+ PaddleOCR, ~1 GB, amd64 only). They run `tidyscan watch /in /out` as uid 1000:
140
+
141
+ ```sh
142
+ docker build -t tidyscan .
143
+ docker run -d --network none --read-only --tmpfs /tmp --env-file scan.env \
144
+ -v ./scans/new:/in -v ./scans/pdf:/out tidyscan
145
+ ```
146
+
147
+ OCR languages for the image: `--build-arg OCR_LANGS="eng nld deu fra"`.
148
+
149
+ ## Limits
150
+
151
+ - Turning upright only for scripts you allow (Latin by default); skew up to 45
152
+ degrees. Photos and nearly blank pages are kept as scanned, and so are phone
153
+ photos of receipts (Tesseract finds no text lines on them).
154
+ - At most 5 minutes of Tesseract per page; a page above 200 million pixels is
155
+ not read at all.
156
+ - OCRmyPDF gets 600 s per document: a long document read by PaddleOCR (about
157
+ 10 s a page) may run out and is then read by Tesseract.
158
+ - PaddleOCR: Latin script only; OCRmyPDF must run in the same Python as
159
+ tidyscan and RapidOCR (the engine is the OCRmyPDF plugin
160
+ `tidyscan.ocrmypdf_paddle`, also usable on its own: `ocrmypdf --plugin
161
+ tidyscan.ocrmypdf_paddle`). `TIDYSCAN_PADDLE_THREADS` sets its threads.
162
+
163
+ ## How well it works
164
+
165
+ Measured with `tests/eval_deskew.py` on 1946 cases from 69 pages, each turned,
166
+ tilted or mirrored by a known amount (06-11-2026):
167
+
168
+ - **Upright**: of 381 text pages that needed a turn, 96% upright and none
169
+ turned the wrong way (OCRmyPDF's `--rotate-pages`: 29%; Tesseract OSD alone:
170
+ 77%, with 53 wrong turns). No upright page was turned away.
171
+ - **Straight**: within 0.5 degrees on 100%, 100% and 96% of the pages tilted
172
+ slightly, medium and steeply. jdeskew with `angle_max=45` measures as well
173
+ but turns no page upright; it, OCRmyPDF, ImageMagick, unpaper and Leptonica
174
+ straighten by rotating the image, which resamples it.
175
+
176
+ Details, the other tools and the pages: [docs/benchmarks.md](docs/benchmarks.md).
177
+
178
+ ## Development
179
+
180
+ With [uv](https://docs.astral.sh/uv/):
181
+
182
+ uv sync
183
+ uv run ruff check && uv run ruff format --check && uv run ty check
184
+ uv run python -m unittest tests.test_tidyscan
185
+
186
+ Tests that need Tesseract, OCRmyPDF or the PaddleOCR models are skipped when
187
+ those are missing. The benchmarks (`tests/eval_*.py`) download their pages and
188
+ are not part of the unit tests.
189
+
190
+ ## Licence
191
+
192
+ BSD-3-Clause, see [LICENSE](LICENSE); the repository follows
193
+ [REUSE](https://reuse.software/). Written by Luuk Visser with Claude Code
194
+ (Claude Opus 5.5).
195
+
196
+ What the optional parts use is not included in this package: OCRmyPDF
197
+ (MPL-2.0), Tesseract, RapidOCR, OpenVINO, OpenCV and PaddleOCR's PP-OCRv5
198
+ models (Apache-2.0; the models come with their own notice). The `full` and
199
+ `paddle` images add Debian packages under their own licences, among them
200
+ Ghostscript (AGPL-3.0, used by OCRmyPDF); the `base` image has no copyleft
201
+ software.
202
+
@@ -0,0 +1,172 @@
1
+ # tidyscan
2
+
3
+ Scanned pages turned upright and straightened into a PDF, losslessly, and
4
+ optionally made searchable.
5
+
6
+ - **Upright**: Tesseract's orientation detection decides the quarter turn,
7
+ only when it is sure and the script is one you allow (Latin by default); an
8
+ unsure upside-down page is settled by reading it both ways round.
9
+ - **Straight**: a page laid crooked on the glass (up to 45 degrees) is
10
+ measured on its text lines and checked by measuring again.
11
+ - **Lossless**: JPEG pages go into the PDF byte for byte, 1-bit pages
12
+ Flate-compressed; the turn is the page's `/Rotate`, the skew a drawing
13
+ matrix. Nothing is resampled. Standard library only.
14
+ - **Searchable** (optional): a text layer by OCRmyPDF, read by Tesseract, or by
15
+ PaddleOCR (PP-OCRv5 on OpenVINO, CPU) for receipts.
16
+
17
+ When anything is unsure, the page is kept as scanned. Written for the scans of
18
+ [scankeyd](https://github.com/luukvisser/scankeyd) (the Scan key of Brother
19
+ network printers to a folder), and usable for any scanned pages.
20
+
21
+ ## Install
22
+
23
+ pip install tidyscan # pages to PDF only
24
+ pip install 'tidyscan[straighten]' # plus Pillow: turning upright and straightening
25
+ pip install 'tidyscan[ocr]' # plus OCRmyPDF: the text layer
26
+ pip install 'tidyscan[paddle]' # plus RapidOCR and OpenVINO: PaddleOCR
27
+
28
+ Python 3.11 or newer, on Linux. Turning and straightening also need the
29
+ `tesseract` program with its `osd` data, OCR its language data (Debian:
30
+ `tesseract-ocr tesseract-ocr-osd tesseract-ocr-nld ...`). PaddleOCR needs its
31
+ models once (checked by sha256, never downloaded by themselves):
32
+
33
+ tidyscan models fetch # into ~/.local/share/tidyscan/models
34
+ tidyscan models path # where they are looked up, and whether they are there
35
+
36
+ ## Use
37
+
38
+ tidyscan --ocr eng+nld -o out.pdf page1.jpg page2.pbm
39
+ tidyscan --ocr nld --engine paddle -o receipt.pdf receipt.jpg
40
+
41
+ JPEG and PBM pages go in as they are. Other formats (PNG, TIFF, every page of
42
+ a multi-page TIFF) are read with Pillow and converted first, to PBM when
43
+ black-and-white, else to JPEG (quality 95): for those the PDF holds the
44
+ conversion. Options: `--dpi` (300), `--no-rotate`, `--no-deskew`,
45
+ `--min-confidence` (3), `--engine`, `-q`. Exit code 0, or 1 for a page that is
46
+ not a JPEG or PBM; an OCR failure writes the PDF without text and says so.
47
+
48
+ ```python
49
+ import tidyscan
50
+ from tidyscan import ocr, pdf
51
+
52
+ pages = [open("page1.jpg", "rb").read()]
53
+ turns, skews = zip(*(tidyscan.straighten(p, 300) for p in pages))
54
+ data = pdf.build(pages, 300, list(turns), list(skews))
55
+ data = ocr.add_text_layer(data, "eng+nld") # raises ocr.OcrError; engine="tesseract" (default), "paddle", "auto"
56
+ ```
57
+
58
+ A page is JPEG bytes, binary PBM (P4) bytes, or an object with `to_pbm()`.
59
+ `straighten()` never raises and logs to the `tidyscan` logger; `turn=` takes a
60
+ turn already known (from the scanner), `budget=` the seconds of Tesseract it
61
+ may spend on a page (300; what is not measured by then is kept as scanned).
62
+
63
+ ### Which OCR engine
64
+
65
+ | engine | words found | words right | 1 page | each further page | memory |
66
+ |---|---|---|---|---|---|
67
+ | **Tesseract 5.5** (`--engine tesseract`, the default) | 90% | 87% | 3.6 s | 2.7 s | 0.15 GB |
68
+ | PaddleOCR PP-OCRv5 (`--engine paddle`) | 83% | 92% | 6.1 s | 9.9 s | 0.9 GB |
69
+
70
+ Tesseract reads letters, documents, forms and book pages better, faster and
71
+ in less memory. PaddleOCR reads receipts, photo captions and tables better
72
+ (it adds far fewer wrong words on tables), and phone photos of receipts that
73
+ Tesseract cannot read at all. `--engine auto` takes PaddleOCR when it is
74
+ installed and the languages are Latin-script ones, else Tesseract; Tesseract
75
+ also takes over a document PaddleOCR fails on. PaddleOCR's Latin recogniser
76
+ reads Dutch, English and some 30 other languages at once. Measured on 45 pages
77
+ in 188 scan-like variants, through the whole pipeline, on 2 cores of an Intel
78
+ N150: [docs/benchmarks.md](docs/benchmarks.md).
79
+
80
+ ## As a service: `tidyscan watch`
81
+
82
+ `tidyscan watch IN OUT` turns document folders into PDFs, oldest first: a
83
+ folder `IN/<function>/<name>/` with the pages (`page-0001.jpg`,
84
+ `page-0002.pbm`, ...) and a `document.json` (optional `"resolution"`, default
85
+ 300; `"pages"`, default the page files in order; `"ocr": true` for a text
86
+ layer), as [scankeyd](https://github.com/luukvisser/scankeyd) writes them.
87
+ Each becomes `OUT/<name>.pdf`, written as a hidden `.part` file and renamed;
88
+ then the folder goes. A document that cannot be read moves to `IN/.failed/`;
89
+ when OCR fails, the PDF is written without text. IN and its function folders
90
+ must be writable for this user, as each folder goes once its PDF is there.
91
+
92
+ | Option | Environment | Default | |
93
+ |---|---|---|---|
94
+ | `--ocr LANGS` | `OCR_LANGUAGES` | `eng` | text layer languages, e.g. `eng+nld` |
95
+ | `--engine` | `OCR_ENGINE` | `tesseract` | `tesseract`, `paddle` or `auto` |
96
+ | `--ocr-folders` | `OCR_FOLDERS` | `ocr` | for documents without an `"ocr"` field: the function folders that get a text layer (comma list, `all`, or empty for none) |
97
+ | `--no-rotate`, `--no-deskew` | `ROTATE`, `DESKEW` | `auto` | `on`, `off` or `auto` (on when Tesseract is there) |
98
+ | `--pdfa` | `PDFA` | `off` | PDF/A-2b for the documents with a text layer (still lossless: 1-bit pages become CCITT G4) |
99
+ | `--status FILE` | `STATUS_FILE` | none | a JSON status file for a monitor: `when`, `last_ok`, `last_file`, `last_error`, `failed`, `waiting`, and the counts `pdfs`, `unreadable`, `no_text` |
100
+ | `--keep-folders` | | off | `OUT/<function>/<name>.pdf` |
101
+ | `--interval` | | 5 | seconds between looks at IN |
102
+ | `--once` | | | convert what is there, then stop |
103
+
104
+ On/off settings take `on`/`off`, `true`/`false`, `yes`/`no` or `1`/`0`; any
105
+ other value stops the start with a message.
106
+
107
+ **Docker**: three images, built from this repository (`docker build ...`):
108
+ `--target base` (pages to PDF only, ~77 MB, no copyleft software), the default
109
+ `full` (with Tesseract and OCRmyPDF, ~480 MB) and `--target paddle` (with
110
+ PaddleOCR, ~1 GB, amd64 only). They run `tidyscan watch /in /out` as uid 1000:
111
+
112
+ ```sh
113
+ docker build -t tidyscan .
114
+ docker run -d --network none --read-only --tmpfs /tmp --env-file scan.env \
115
+ -v ./scans/new:/in -v ./scans/pdf:/out tidyscan
116
+ ```
117
+
118
+ OCR languages for the image: `--build-arg OCR_LANGS="eng nld deu fra"`.
119
+
120
+ ## Limits
121
+
122
+ - Turning upright only for scripts you allow (Latin by default); skew up to 45
123
+ degrees. Photos and nearly blank pages are kept as scanned, and so are phone
124
+ photos of receipts (Tesseract finds no text lines on them).
125
+ - At most 5 minutes of Tesseract per page; a page above 200 million pixels is
126
+ not read at all.
127
+ - OCRmyPDF gets 600 s per document: a long document read by PaddleOCR (about
128
+ 10 s a page) may run out and is then read by Tesseract.
129
+ - PaddleOCR: Latin script only; OCRmyPDF must run in the same Python as
130
+ tidyscan and RapidOCR (the engine is the OCRmyPDF plugin
131
+ `tidyscan.ocrmypdf_paddle`, also usable on its own: `ocrmypdf --plugin
132
+ tidyscan.ocrmypdf_paddle`). `TIDYSCAN_PADDLE_THREADS` sets its threads.
133
+
134
+ ## How well it works
135
+
136
+ Measured with `tests/eval_deskew.py` on 1946 cases from 69 pages, each turned,
137
+ tilted or mirrored by a known amount (06-11-2026):
138
+
139
+ - **Upright**: of 381 text pages that needed a turn, 96% upright and none
140
+ turned the wrong way (OCRmyPDF's `--rotate-pages`: 29%; Tesseract OSD alone:
141
+ 77%, with 53 wrong turns). No upright page was turned away.
142
+ - **Straight**: within 0.5 degrees on 100%, 100% and 96% of the pages tilted
143
+ slightly, medium and steeply. jdeskew with `angle_max=45` measures as well
144
+ but turns no page upright; it, OCRmyPDF, ImageMagick, unpaper and Leptonica
145
+ straighten by rotating the image, which resamples it.
146
+
147
+ Details, the other tools and the pages: [docs/benchmarks.md](docs/benchmarks.md).
148
+
149
+ ## Development
150
+
151
+ With [uv](https://docs.astral.sh/uv/):
152
+
153
+ uv sync
154
+ uv run ruff check && uv run ruff format --check && uv run ty check
155
+ uv run python -m unittest tests.test_tidyscan
156
+
157
+ Tests that need Tesseract, OCRmyPDF or the PaddleOCR models are skipped when
158
+ those are missing. The benchmarks (`tests/eval_*.py`) download their pages and
159
+ are not part of the unit tests.
160
+
161
+ ## Licence
162
+
163
+ BSD-3-Clause, see [LICENSE](LICENSE); the repository follows
164
+ [REUSE](https://reuse.software/). Written by Luuk Visser with Claude Code
165
+ (Claude Opus 5.5).
166
+
167
+ What the optional parts use is not included in this package: OCRmyPDF
168
+ (MPL-2.0), Tesseract, RapidOCR, OpenVINO, OpenCV and PaddleOCR's PP-OCRv5
169
+ models (Apache-2.0; the models come with their own notice). The `full` and
170
+ `paddle` images add Debian packages under their own licences, among them
171
+ Ghostscript (AGPL-3.0, used by OCRmyPDF); the `base` image has no copyleft
172
+ software.
@@ -0,0 +1,73 @@
1
+ [build-system]
2
+ requires = ["flit_core==3.12.0"]
3
+ build-backend = "flit_core.buildapi"
4
+
5
+ [project]
6
+ name = "tidyscan"
7
+ dynamic = ["version", "description"]
8
+ readme = "README.md"
9
+ license = "BSD-3-Clause"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Luuk Visser" }]
13
+ keywords = ["scan", "deskew", "orientation", "pdf", "ocr", "tesseract", "paddleocr"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Topic :: Multimedia :: Graphics :: Graphics Conversion",
19
+ "Topic :: Scientific/Engineering :: Image Recognition",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ straighten = ["Pillow>=10"]
25
+ ocr = ["Pillow>=10", "ocrmypdf>=16"]
26
+ # PaddleOCR on OpenVINO (CPU) as the OCR engine; the models are separate files (see README)
27
+ paddle = ["Pillow>=10", "ocrmypdf>=16,<18", "rapidocr>=3.10,<4", "openvino>=2025.1"]
28
+
29
+ [project.urls]
30
+ Source = "https://github.com/luukvisser/tidyscan"
31
+ Issues = "https://github.com/luukvisser/tidyscan/issues"
32
+ Changelog = "https://github.com/luukvisser/tidyscan/blob/main/CHANGELOG.md"
33
+
34
+ [project.scripts]
35
+ tidyscan = "tidyscan.__main__:main"
36
+
37
+ [tool.flit.sdist]
38
+ include = ["CHANGELOG.md", "tests/", "tools/"]
39
+
40
+ [dependency-groups]
41
+ # Lint, type check and the tests that need no Tesseract; `uv sync` installs these.
42
+ # OCRmyPDF here so ty checks the plugin against its API, and its tests run.
43
+ dev = ["ruff==0.17.3", "ty==0.0.95", "Pillow>=10", "ocrmypdf>=16,<18"]
44
+
45
+ [tool.ruff]
46
+ line-length = 120
47
+ target-version = "py311"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "W", "I", "B", "UP", "ANN", "TC", "FA"]
51
+
52
+ [tool.ruff.lint.per-file-ignores]
53
+ # The benchmarks (not part of the package or its tests): long report lines, helpers without annotations
54
+ "tests/eval_*.py" = ["E501", "E702", "ANN001", "ANN202"]
55
+
56
+ [tool.ty.terminal]
57
+ # warnings (e.g. deprecated APIs) fail the check too
58
+ error-on-warning = true
59
+
60
+ [tool.ty.rules]
61
+ # on top of ty's defaults: no bare generics (missing-override-decorator needs typing.override, 3.12+)
62
+ missing-type-argument = "error"
63
+
64
+ [tool.ty.src]
65
+ # The benchmarks run in their own image (tests/eval_deskew.Dockerfile) with libraries the package does
66
+ # not depend on (OpenCV, numpy, the other deskew tools); ruff still checks them.
67
+ exclude = ["tests/eval_*.py"]
68
+
69
+ [tool.ty.analysis]
70
+ # rapidocr: the optional PaddleOCR runtime (with OpenVINO and OpenCV, ~300 MB), imported only when
71
+ # that engine is used; not worth installing just to type check two imports.
72
+ # yaml: a build-time dependency of tools/models.py only, pinned in tools/convert-requirements.txt.
73
+ allowed-unresolved-imports = ["rapidocr", "yaml"]
@@ -0,0 +1,156 @@
1
+ """Auto-rotate and deskew scanned pages into a lossless PDF, with an optional OCR text layer.
2
+
3
+ turn, angle = tidyscan.straighten(page, 300)
4
+ data = tidyscan.pdf.build([page], 300, [turn], [angle])
5
+ data = tidyscan.ocr.add_text_layer(data, "eng+nld") # Tesseract; engine="paddle" for receipts
6
+
7
+ A page is JPEG bytes, binary PBM (P4) bytes, or an object with to_pbm().
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ from . import _budget, hocr, models, ocr, orientation, paddle, pdf, skew
15
+ from ._page import Page, PbmSource, encoded
16
+ from .ocr import OcrError
17
+ from .pdf import UnsupportedPage
18
+
19
+ __version__ = "0.3.0"
20
+ __all__ = [
21
+ "OcrError",
22
+ "Page",
23
+ "PbmSource",
24
+ "UnsupportedPage",
25
+ "hocr",
26
+ "models",
27
+ "ocr",
28
+ "orientation",
29
+ "paddle",
30
+ "pdf",
31
+ "skew",
32
+ "straighten",
33
+ ]
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ STEEP_EDGE = 2.0 # degrees from +-45: a steep-path answer there is as likely the other quarter turn
38
+ BUDGET = 300.0 # seconds of Tesseract per page at most (see _budget)
39
+ MAX_PIXELS = 200_000_000 # above Pillow's own refusal (2 x 89.5 M pixels); an A4 page at 1200 dpi is 140 M
40
+
41
+
42
+ def straighten(
43
+ page: Page,
44
+ dpi: int,
45
+ *,
46
+ turn: int = 0,
47
+ rotate: bool = True,
48
+ deskew: bool = True,
49
+ min_confidence: float = 3.0,
50
+ scripts: tuple[str, ...] = ("Latin",),
51
+ deskew_min: float = 0.3,
52
+ workdir: str | None = None,
53
+ budget: float | None = BUDGET,
54
+ ) -> tuple[int, float]:
55
+ """(clockwise turn 0/90/180/270, skew in degrees counter-clockwise) for pdf.build; 0 for what is off,
56
+ unsure or unavailable.
57
+
58
+ turn: a turn already known (from the scanner); it is kept and not detected. deskew_min: smaller
59
+ skews are not visible and left alone. budget: seconds of Tesseract for this page at most (None: no
60
+ limit); what is not measured by then is kept as scanned. A page larger than MAX_PIXELS is not read
61
+ at all. Never raises: straightening is a nicety and must not cost a page (a JPEG that Pillow
62
+ cannot decode is still a page the PDF can carry)."""
63
+ if _too_large(page):
64
+ return turn, 0.0
65
+ token = _budget.start(budget)
66
+ try:
67
+ return _straighten(page, dpi, turn, rotate, deskew, min_confidence, scripts, deskew_min, workdir)
68
+ finally:
69
+ _budget.end(token)
70
+
71
+
72
+ def _too_large(page: Page) -> bool:
73
+ try:
74
+ data, ext = encoded(page)
75
+ w, h = pdf.pbm_info(data)[:2] if ext == "pbm" else pdf.jpeg_info(data)[:2]
76
+ except Exception: # noqa: BLE001 (the PDF writer reports a damaged page)
77
+ return False
78
+ if w * h > MAX_PIXELS:
79
+ log.warning("page of %d x %d pixels: too large to read; kept as scanned", w, h)
80
+ return True
81
+ return False
82
+
83
+
84
+ def _straighten(
85
+ page: Page,
86
+ dpi: int,
87
+ turn: int,
88
+ rotate: bool,
89
+ deskew: bool,
90
+ min_confidence: float,
91
+ scripts: tuple[str, ...],
92
+ deskew_min: float,
93
+ workdir: str | None,
94
+ ) -> tuple[int, float]:
95
+ # Skew first, then the orientation of the straightened page: OSD cannot read a page tilted by
96
+ # more than a few degrees. A 180-degree turn leaves the text lines' angle as it is; a quarter
97
+ # turn means the lines were measured across the text, so the skew is measured again upright.
98
+ known = bool(turn)
99
+ found = _detect(page, dpi, turn, workdir) if deskew else None
100
+ base = 0
101
+ if found is None and deskew and rotate and not known:
102
+ # A page laid at more than 45 degrees (a receipt at about 60, upside down, 01-11-2026): the skew
103
+ # search covers +-45, so its text rows only show a quarter turn on. Measured there, the skew is
104
+ # an ordinary one, and OSD then only has to tell 90 from 270.
105
+ steep = _detect(page, dpi, 90, workdir)
106
+ if steep is not None and abs(abs(steep) - 45) < STEEP_EDGE:
107
+ # a chart with slanted axis labels came out a quarter plus about 45 degrees (benchmark 05-11-2026)
108
+ log.info("skew unsure (%.2f degrees a quarter turn on: too near 45); page kept as scanned", steep)
109
+ elif steep is not None:
110
+ base, found = 90, steep
111
+ log.info("page lies at more than 45 degrees; measured a quarter turn on")
112
+ angle = found if found is not None and abs(found) >= deskew_min else 0.0
113
+ if not known and rotate:
114
+ try:
115
+ more, sure = orientation.detect(page, workdir, min_confidence, scripts, skew=angle, quarter_turn=base)
116
+ except Exception as e: # noqa: BLE001
117
+ log.warning("orientation not determined (%s); page kept as scanned", e)
118
+ more, sure = 0, False
119
+ if base and not sure:
120
+ # The steep path's quarter turn is only half an answer: 90 and 270 level the same lines, and
121
+ # only OSD on the turned page tells them apart. Unsure, it was kept, and every wrong turn in the
122
+ # benchmark (10, 05-11-2026) came from here. Rotations commute, so the angle measured in the
123
+ # turned frame straightens the page as scanned too: sideways, but level. First the words
124
+ # decide, as for an unsure upside-down page.
125
+ try:
126
+ by_words = orientation.quarter_by_words(page, workdir, skew=angle)
127
+ except Exception as e: # noqa: BLE001
128
+ log.warning("words not read (%s)", e)
129
+ by_words = None
130
+ if by_words:
131
+ base, more = by_words, 0
132
+ else:
133
+ log.info("quarter turn not confirmed by the orientation check; page kept sideways")
134
+ base = 0
135
+ turn = (base + more) % 360
136
+ if more in (90, 270):
137
+ angle = _skew(page, dpi, turn, deskew, deskew_min, workdir)
138
+ if angle:
139
+ log.info("page straightened by %.2f degrees", angle)
140
+ return turn, angle
141
+
142
+
143
+ def _detect(page: Page, dpi: int, turn: int, workdir: str | None) -> float | None:
144
+ """skew.detect, None when unsure or failing."""
145
+ try:
146
+ return skew.detect(page, dpi, turn, workdir)
147
+ except Exception as e: # noqa: BLE001
148
+ log.warning("skew not measured (%s); page kept as scanned", e)
149
+ return None
150
+
151
+
152
+ def _skew(page: Page, dpi: int, turn: int, deskew: bool, deskew_min: float, workdir: str | None) -> float:
153
+ if not deskew:
154
+ return 0.0
155
+ found = _detect(page, dpi, turn, workdir)
156
+ return found if found is not None and abs(found) >= deskew_min else 0.0