natocr 2.0.0__tar.gz → 2.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.
- {natocr-2.0.0 → natocr-2.1.0}/PKG-INFO +38 -1
- {natocr-2.0.0 → natocr-2.1.0}/README.md +37 -0
- {natocr-2.0.0 → natocr-2.1.0}/natocr/__init__.py +3 -2
- natocr-2.1.0/natocr/models.py +230 -0
- natocr-2.1.0/natocr/py.typed +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/natocr/windows.py +15 -7
- {natocr-2.0.0 → natocr-2.1.0}/natocr.egg-info/PKG-INFO +38 -1
- {natocr-2.0.0 → natocr-2.1.0}/natocr.egg-info/SOURCES.txt +1 -0
- {natocr-2.0.0 → natocr-2.1.0}/pyproject.toml +5 -1
- natocr-2.1.0/tests/test_models.py +141 -0
- {natocr-2.0.0 → natocr-2.1.0}/tests/test_package.py +8 -1
- natocr-2.0.0/natocr/models.py +0 -108
- natocr-2.0.0/tests/test_models.py +0 -59
- {natocr-2.0.0 → natocr-2.1.0}/LICENSE +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/natocr/core.py +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/natocr/macos.py +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/natocr.egg-info/dependency_links.txt +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/natocr.egg-info/requires.txt +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/natocr.egg-info/top_level.txt +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/setup.cfg +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/tests/test_integration_macos.py +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/tests/test_integration_windows.py +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/tests/test_macos.py +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/tests/test_ocr.py +0 -0
- {natocr-2.0.0 → natocr-2.1.0}/tests/test_windows.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: natocr
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Native OCR library using platform-specific frameworks (macOS Vision, Windows Runtime OCR)
|
|
5
5
|
Author-email: alfredchiesa <alfred.personal@icloud.com>
|
|
6
6
|
Maintainer-email: alfredchiesa <alfred.personal@icloud.com>
|
|
@@ -58,6 +58,14 @@ than third-party alternatives like Tesseract. **natocr** makes reaching for them
|
|
|
58
58
|
painless via one clean Python API instead of wrangling with Objective-C bridges
|
|
59
59
|
or WinRT async plumbing.
|
|
60
60
|
|
|
61
|
+
## Notable Updates
|
|
62
|
+
|
|
63
|
+
- **v2.0.0** (2026-06-25) - batch & async support: [`recognize_many()`](#batch-and-async) plus awaitable [`arecognize()`](#batch-and-async) / [`arecognize_many()`](#batch-and-async) for concurrent, non-blocking OCR
|
|
64
|
+
- **v1.6.1** (2026-06-04) - animated PNG and multi-image HEIF support
|
|
65
|
+
- **v1.6.0** (2026-06-04) - multi-page documents and DjVu support
|
|
66
|
+
- **v1.5.0** (2026-06-04) - JPEG 2000, JPEG XL, and JPEG XR / HD Photo support
|
|
67
|
+
- **v1.4.0** (2026-06-04) - HEIC / HEIF (iPhone photo) support
|
|
68
|
+
|
|
61
69
|
## Install
|
|
62
70
|
|
|
63
71
|
```bash
|
|
@@ -71,6 +79,9 @@ The right native backend (Vision on macOS, Windows Runtime OCR on Windows) is
|
|
|
71
79
|
pulled in automatically for your platform - no OS-specific install command to
|
|
72
80
|
pick.
|
|
73
81
|
|
|
82
|
+
natocr ships a `py.typed` marker, so the public API is fully typed - mypy,
|
|
83
|
+
pyright, and your editor pick up the hints with no stubs needed.
|
|
84
|
+
|
|
74
85
|
## Quick start
|
|
75
86
|
|
|
76
87
|
```python
|
|
@@ -122,6 +133,32 @@ page.lines # ['Acme Coffee', 'Latte $4.50'] - elements grouped into lines
|
|
|
122
133
|
page.words # list of TextElement with non-empty text
|
|
123
134
|
```
|
|
124
135
|
|
|
136
|
+
Want the confidence and bounds for each line (not just the text)? `text_lines`
|
|
137
|
+
gives you the same grouping as [`TextLine`](https://alfredchiesa.github.io/natocr/api/#natocr.TextLine)
|
|
138
|
+
objects, and `paragraphs` merges lines into blocks by their vertical gaps - both
|
|
139
|
+
aggregate confidence across their elements:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
for line in page.text_lines:
|
|
143
|
+
print(line.text, line.confidence, line.bounds.bounds)
|
|
144
|
+
|
|
145
|
+
for para in page.paragraphs: # lines joined by newlines, confidence averaged
|
|
146
|
+
print(para.confidence, para.text)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Filtering by Confidence
|
|
150
|
+
|
|
151
|
+
Drop the low-confidence noise with `filter()` - it hands back a new `OCRResult`
|
|
152
|
+
keeping only detections at or above the threshold:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
clean = page.filter(0.8) # only elements >= 0.8 confidence
|
|
156
|
+
print(clean.text)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Elements without a confidence score (Windows OCR doesn't report one) are kept by
|
|
160
|
+
default since they can't be judged - pass `drop_unknown=True` to drop them too.
|
|
161
|
+
|
|
125
162
|
### Detection Language
|
|
126
163
|
|
|
127
164
|
Pick a different recognition language, and inspect what the current platform
|
|
@@ -9,6 +9,14 @@ than third-party alternatives like Tesseract. **natocr** makes reaching for them
|
|
|
9
9
|
painless via one clean Python API instead of wrangling with Objective-C bridges
|
|
10
10
|
or WinRT async plumbing.
|
|
11
11
|
|
|
12
|
+
## Notable Updates
|
|
13
|
+
|
|
14
|
+
- **v2.0.0** (2026-06-25) - batch & async support: [`recognize_many()`](#batch-and-async) plus awaitable [`arecognize()`](#batch-and-async) / [`arecognize_many()`](#batch-and-async) for concurrent, non-blocking OCR
|
|
15
|
+
- **v1.6.1** (2026-06-04) - animated PNG and multi-image HEIF support
|
|
16
|
+
- **v1.6.0** (2026-06-04) - multi-page documents and DjVu support
|
|
17
|
+
- **v1.5.0** (2026-06-04) - JPEG 2000, JPEG XL, and JPEG XR / HD Photo support
|
|
18
|
+
- **v1.4.0** (2026-06-04) - HEIC / HEIF (iPhone photo) support
|
|
19
|
+
|
|
12
20
|
## Install
|
|
13
21
|
|
|
14
22
|
```bash
|
|
@@ -22,6 +30,9 @@ The right native backend (Vision on macOS, Windows Runtime OCR on Windows) is
|
|
|
22
30
|
pulled in automatically for your platform - no OS-specific install command to
|
|
23
31
|
pick.
|
|
24
32
|
|
|
33
|
+
natocr ships a `py.typed` marker, so the public API is fully typed - mypy,
|
|
34
|
+
pyright, and your editor pick up the hints with no stubs needed.
|
|
35
|
+
|
|
25
36
|
## Quick start
|
|
26
37
|
|
|
27
38
|
```python
|
|
@@ -73,6 +84,32 @@ page.lines # ['Acme Coffee', 'Latte $4.50'] - elements grouped into lines
|
|
|
73
84
|
page.words # list of TextElement with non-empty text
|
|
74
85
|
```
|
|
75
86
|
|
|
87
|
+
Want the confidence and bounds for each line (not just the text)? `text_lines`
|
|
88
|
+
gives you the same grouping as [`TextLine`](https://alfredchiesa.github.io/natocr/api/#natocr.TextLine)
|
|
89
|
+
objects, and `paragraphs` merges lines into blocks by their vertical gaps - both
|
|
90
|
+
aggregate confidence across their elements:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
for line in page.text_lines:
|
|
94
|
+
print(line.text, line.confidence, line.bounds.bounds)
|
|
95
|
+
|
|
96
|
+
for para in page.paragraphs: # lines joined by newlines, confidence averaged
|
|
97
|
+
print(para.confidence, para.text)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Filtering by Confidence
|
|
101
|
+
|
|
102
|
+
Drop the low-confidence noise with `filter()` - it hands back a new `OCRResult`
|
|
103
|
+
keeping only detections at or above the threshold:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
clean = page.filter(0.8) # only elements >= 0.8 confidence
|
|
107
|
+
print(clean.text)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Elements without a confidence score (Windows OCR doesn't report one) are kept by
|
|
111
|
+
default since they can't be judged - pass `drop_unknown=True` to drop them too.
|
|
112
|
+
|
|
76
113
|
### Detection Language
|
|
77
114
|
|
|
78
115
|
Pick a different recognition language, and inspect what the current platform
|
|
@@ -7,9 +7,9 @@ this package provides ocr functionality using native frameworks:
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from .core import OCR
|
|
10
|
-
from .models import BoundingBox, OCRResult, TextElement
|
|
10
|
+
from .models import BoundingBox, OCRResult, TextElement, TextLine
|
|
11
11
|
|
|
12
|
-
__version__ = "2.
|
|
12
|
+
__version__ = "2.1.0"
|
|
13
13
|
__author__ = "alfredchiesa"
|
|
14
14
|
__email__ = "alfred.personal@icloud.com"
|
|
15
15
|
|
|
@@ -17,5 +17,6 @@ __all__ = [
|
|
|
17
17
|
"OCR",
|
|
18
18
|
"OCRResult",
|
|
19
19
|
"TextElement",
|
|
20
|
+
"TextLine",
|
|
20
21
|
"BoundingBox",
|
|
21
22
|
]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
data models for ocr results
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class BoundingBox:
|
|
11
|
+
"""Pixel-space bounding box for a piece of detected text.
|
|
12
|
+
|
|
13
|
+
The origin is the top-left of the image, with ``y`` growing downward.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
x: left edge, in pixels.
|
|
17
|
+
y: top edge, in pixels.
|
|
18
|
+
width: box width, in pixels.
|
|
19
|
+
height: box height, in pixels.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
x: float
|
|
23
|
+
y: float
|
|
24
|
+
width: float
|
|
25
|
+
height: float
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def bounds(self) -> Tuple[float, float, float, float]:
|
|
29
|
+
"""The box as an ``(x, y, width, height)`` tuple."""
|
|
30
|
+
return (self.x, self.y, self.width, self.height)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TextElement:
|
|
35
|
+
"""A single detected piece of text with its location.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
text: the recognized string.
|
|
39
|
+
bounds: where it was found in the image.
|
|
40
|
+
confidence: recognition confidence in ``0.0..1.0``, or ``None`` when the
|
|
41
|
+
backend doesn't report one (Windows OCR doesn't).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
text: str
|
|
45
|
+
bounds: BoundingBox
|
|
46
|
+
confidence: Optional[float] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class TextLine:
|
|
51
|
+
"""A run of detected text grouped into one line.
|
|
52
|
+
|
|
53
|
+
The structured cousin of [`OCRResult.lines`][natocr.OCRResult.lines] - same
|
|
54
|
+
grouping, but each line keeps its elements, an aggregated confidence, and
|
|
55
|
+
the box that wraps them. Also the shape
|
|
56
|
+
[`OCRResult.paragraphs`][natocr.OCRResult.paragraphs] returns.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
text: the line's text, its elements joined left-to-right.
|
|
60
|
+
elements: the detections that make up the line.
|
|
61
|
+
confidence: mean confidence across the elements that report one, or
|
|
62
|
+
``None`` when none do (Windows OCR doesn't).
|
|
63
|
+
bounds: the box enclosing every element in the line.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
text: str
|
|
67
|
+
elements: List[TextElement]
|
|
68
|
+
confidence: Optional[float]
|
|
69
|
+
bounds: BoundingBox
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _mean_confidence(elements: List[TextElement]) -> Optional[float]:
|
|
73
|
+
"""average of the confidences that exist, or none if there aren't any"""
|
|
74
|
+
scores = [e.confidence for e in elements if e.confidence is not None]
|
|
75
|
+
return sum(scores) / len(scores) if scores else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _union_bounds(elements: List[TextElement]) -> BoundingBox:
|
|
79
|
+
"""smallest box that wraps every element"""
|
|
80
|
+
left = min(e.bounds.x for e in elements)
|
|
81
|
+
top = min(e.bounds.y for e in elements)
|
|
82
|
+
right = max(e.bounds.x + e.bounds.width for e in elements)
|
|
83
|
+
bottom = max(e.bounds.y + e.bounds.height for e in elements)
|
|
84
|
+
return BoundingBox(x=left, y=top, width=right - left, height=bottom - top)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class OCRResult:
|
|
89
|
+
"""Everything an OCR pass found in one image.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
text: all detected text joined into a single string.
|
|
93
|
+
confidence: average confidence across detections, or ``None`` if the
|
|
94
|
+
backend doesn't report confidence.
|
|
95
|
+
elements: per-detection breakdown with text, bounds, and confidence.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
text: str
|
|
99
|
+
confidence: Optional[float] = None
|
|
100
|
+
elements: List[TextElement] = None
|
|
101
|
+
|
|
102
|
+
def __post_init__(self):
|
|
103
|
+
# default the mutable elements list when none was passed
|
|
104
|
+
if self.elements is None:
|
|
105
|
+
self.elements = []
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def words(self) -> List[TextElement]:
|
|
109
|
+
"""The elements that contain non-whitespace text."""
|
|
110
|
+
return [elem for elem in self.elements if elem.text.strip()]
|
|
111
|
+
|
|
112
|
+
def _group_lines(self) -> List[List[TextElement]]:
|
|
113
|
+
"""group elements into lines by vertical proximity, in reading order"""
|
|
114
|
+
groups = []
|
|
115
|
+
current_line = []
|
|
116
|
+
current_y = None
|
|
117
|
+
|
|
118
|
+
for elem in sorted(self.elements, key=lambda e: (e.bounds.y, e.bounds.x)):
|
|
119
|
+
if (
|
|
120
|
+
current_y is None
|
|
121
|
+
or abs(elem.bounds.y - current_y) < elem.bounds.height * 0.5
|
|
122
|
+
):
|
|
123
|
+
current_line.append(elem)
|
|
124
|
+
current_y = elem.bounds.y
|
|
125
|
+
else:
|
|
126
|
+
if current_line:
|
|
127
|
+
groups.append(current_line)
|
|
128
|
+
current_line = [elem]
|
|
129
|
+
current_y = elem.bounds.y
|
|
130
|
+
|
|
131
|
+
if current_line:
|
|
132
|
+
groups.append(current_line)
|
|
133
|
+
|
|
134
|
+
return groups
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def lines(self) -> List[str]:
|
|
138
|
+
"""Detected text grouped into lines by vertical position.
|
|
139
|
+
|
|
140
|
+
Elements whose ``y`` are close together are treated as one line and
|
|
141
|
+
joined left-to-right. Falls back to ``[text]`` (or ``[]``) when there
|
|
142
|
+
are no elements.
|
|
143
|
+
"""
|
|
144
|
+
if not self.elements:
|
|
145
|
+
return [self.text] if self.text else []
|
|
146
|
+
return [" ".join(e.text for e in group) for group in self._group_lines()]
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def text_lines(self) -> List[TextLine]:
|
|
150
|
+
"""Detected lines as [TextLine][natocr.TextLine] objects.
|
|
151
|
+
|
|
152
|
+
Same grouping as [lines][natocr.OCRResult.lines], but each line carries
|
|
153
|
+
its elements, an aggregated confidence (mean over the elements that
|
|
154
|
+
report one), and the box that wraps them. Empty when there are no
|
|
155
|
+
positioned elements.
|
|
156
|
+
"""
|
|
157
|
+
return [
|
|
158
|
+
TextLine(
|
|
159
|
+
text=" ".join(e.text for e in group),
|
|
160
|
+
elements=group,
|
|
161
|
+
confidence=_mean_confidence(group),
|
|
162
|
+
bounds=_union_bounds(group),
|
|
163
|
+
)
|
|
164
|
+
for group in self._group_lines()
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def paragraphs(self) -> List[TextLine]:
|
|
169
|
+
"""Lines merged into paragraphs by the vertical gaps between them.
|
|
170
|
+
|
|
171
|
+
Walks the lines top-to-bottom and starts a new paragraph whenever the
|
|
172
|
+
gap to the next line is more than ~1.5x the line's height. Each
|
|
173
|
+
paragraph comes back in the same [TextLine][natocr.TextLine] shape - its
|
|
174
|
+
``text`` is the member lines joined by newlines, with ``confidence`` and
|
|
175
|
+
``bounds`` aggregated across all of their elements.
|
|
176
|
+
"""
|
|
177
|
+
lines = self.text_lines
|
|
178
|
+
if not lines:
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
# break on a big vertical gap, otherwise keep stacking lines
|
|
182
|
+
groups = [[lines[0]]]
|
|
183
|
+
for prev, line in zip(lines, lines[1:]):
|
|
184
|
+
gap = line.bounds.y - (prev.bounds.y + prev.bounds.height)
|
|
185
|
+
if gap > prev.bounds.height * 1.5:
|
|
186
|
+
groups.append([line])
|
|
187
|
+
else:
|
|
188
|
+
groups[-1].append(line)
|
|
189
|
+
|
|
190
|
+
paragraphs = []
|
|
191
|
+
for group in groups:
|
|
192
|
+
elements = [e for line in group for e in line.elements]
|
|
193
|
+
paragraphs.append(
|
|
194
|
+
TextLine(
|
|
195
|
+
text="\n".join(line.text for line in group),
|
|
196
|
+
elements=elements,
|
|
197
|
+
confidence=_mean_confidence(elements),
|
|
198
|
+
bounds=_union_bounds(elements),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return paragraphs
|
|
202
|
+
|
|
203
|
+
def filter(
|
|
204
|
+
self, min_confidence: float, *, drop_unknown: bool = False
|
|
205
|
+
) -> "OCRResult":
|
|
206
|
+
"""A copy keeping only elements at or above ``min_confidence``.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
min_confidence: lowest confidence to keep, in ``0.0..1.0``.
|
|
210
|
+
drop_unknown: what to do with elements that have no confidence
|
|
211
|
+
(Windows OCR never reports one). ``False`` (the default) keeps
|
|
212
|
+
them, since they can't be judged; ``True`` drops them.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
A new [OCRResult][natocr.OCRResult] holding the surviving elements,
|
|
216
|
+
with ``text`` and ``confidence`` recomputed from them.
|
|
217
|
+
"""
|
|
218
|
+
kept = []
|
|
219
|
+
for elem in self.elements:
|
|
220
|
+
if elem.confidence is None:
|
|
221
|
+
if not drop_unknown:
|
|
222
|
+
kept.append(elem)
|
|
223
|
+
elif elem.confidence >= min_confidence:
|
|
224
|
+
kept.append(elem)
|
|
225
|
+
|
|
226
|
+
return OCRResult(
|
|
227
|
+
text=" ".join(e.text for e in kept),
|
|
228
|
+
confidence=_mean_confidence(kept),
|
|
229
|
+
elements=kept,
|
|
230
|
+
)
|
|
File without changes
|
|
@@ -123,14 +123,13 @@ class WindowsOCR:
|
|
|
123
123
|
"""process windows ocr result into ocr result"""
|
|
124
124
|
elements = []
|
|
125
125
|
full_text_parts = []
|
|
126
|
-
total_confidence = 0.0
|
|
127
|
-
valid_lines = 0
|
|
128
126
|
|
|
129
127
|
for line in result.lines:
|
|
130
128
|
line_text = line.text
|
|
131
129
|
if line_text.strip():
|
|
132
|
-
#
|
|
133
|
-
|
|
130
|
+
# word rect when we have one, else the line rect
|
|
131
|
+
source = line.words[0] if line.words else line
|
|
132
|
+
bbox = source.bounding_rect
|
|
134
133
|
|
|
135
134
|
# convert to pixel coordinates
|
|
136
135
|
x = bbox.x
|
|
@@ -138,21 +137,30 @@ class WindowsOCR:
|
|
|
138
137
|
width = bbox.width
|
|
139
138
|
height = bbox.height
|
|
140
139
|
|
|
140
|
+
# winrt ocr doesn't expose confidence today, so this is None in
|
|
141
|
+
# practice - but grab it best-effort in case a build ever does
|
|
142
|
+
confidence = getattr(source, "confidence", None)
|
|
143
|
+
|
|
141
144
|
# create bounding box and text element
|
|
142
145
|
bounds = BoundingBox(x=x, y=y, width=width, height=height)
|
|
143
|
-
element = TextElement(
|
|
146
|
+
element = TextElement(
|
|
147
|
+
text=line_text, bounds=bounds, confidence=confidence
|
|
148
|
+
)
|
|
144
149
|
elements.append(element)
|
|
145
150
|
|
|
146
151
|
# accumulate text
|
|
147
152
|
full_text_parts.append(line_text)
|
|
148
|
-
valid_lines += 1
|
|
149
153
|
|
|
150
154
|
# join text parts
|
|
151
155
|
full_text = " ".join(full_text_parts)
|
|
152
156
|
|
|
157
|
+
# mean of whatever confidences exist (none today), mirroring macos
|
|
158
|
+
scores = [e.confidence for e in elements if e.confidence is not None]
|
|
159
|
+
avg_confidence = sum(scores) / len(scores) if scores else None
|
|
160
|
+
|
|
153
161
|
return OCRResult(
|
|
154
162
|
text=full_text,
|
|
155
|
-
confidence=
|
|
163
|
+
confidence=avg_confidence,
|
|
156
164
|
elements=elements,
|
|
157
165
|
)
|
|
158
166
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: natocr
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Native OCR library using platform-specific frameworks (macOS Vision, Windows Runtime OCR)
|
|
5
5
|
Author-email: alfredchiesa <alfred.personal@icloud.com>
|
|
6
6
|
Maintainer-email: alfredchiesa <alfred.personal@icloud.com>
|
|
@@ -58,6 +58,14 @@ than third-party alternatives like Tesseract. **natocr** makes reaching for them
|
|
|
58
58
|
painless via one clean Python API instead of wrangling with Objective-C bridges
|
|
59
59
|
or WinRT async plumbing.
|
|
60
60
|
|
|
61
|
+
## Notable Updates
|
|
62
|
+
|
|
63
|
+
- **v2.0.0** (2026-06-25) - batch & async support: [`recognize_many()`](#batch-and-async) plus awaitable [`arecognize()`](#batch-and-async) / [`arecognize_many()`](#batch-and-async) for concurrent, non-blocking OCR
|
|
64
|
+
- **v1.6.1** (2026-06-04) - animated PNG and multi-image HEIF support
|
|
65
|
+
- **v1.6.0** (2026-06-04) - multi-page documents and DjVu support
|
|
66
|
+
- **v1.5.0** (2026-06-04) - JPEG 2000, JPEG XL, and JPEG XR / HD Photo support
|
|
67
|
+
- **v1.4.0** (2026-06-04) - HEIC / HEIF (iPhone photo) support
|
|
68
|
+
|
|
61
69
|
## Install
|
|
62
70
|
|
|
63
71
|
```bash
|
|
@@ -71,6 +79,9 @@ The right native backend (Vision on macOS, Windows Runtime OCR on Windows) is
|
|
|
71
79
|
pulled in automatically for your platform - no OS-specific install command to
|
|
72
80
|
pick.
|
|
73
81
|
|
|
82
|
+
natocr ships a `py.typed` marker, so the public API is fully typed - mypy,
|
|
83
|
+
pyright, and your editor pick up the hints with no stubs needed.
|
|
84
|
+
|
|
74
85
|
## Quick start
|
|
75
86
|
|
|
76
87
|
```python
|
|
@@ -122,6 +133,32 @@ page.lines # ['Acme Coffee', 'Latte $4.50'] - elements grouped into lines
|
|
|
122
133
|
page.words # list of TextElement with non-empty text
|
|
123
134
|
```
|
|
124
135
|
|
|
136
|
+
Want the confidence and bounds for each line (not just the text)? `text_lines`
|
|
137
|
+
gives you the same grouping as [`TextLine`](https://alfredchiesa.github.io/natocr/api/#natocr.TextLine)
|
|
138
|
+
objects, and `paragraphs` merges lines into blocks by their vertical gaps - both
|
|
139
|
+
aggregate confidence across their elements:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
for line in page.text_lines:
|
|
143
|
+
print(line.text, line.confidence, line.bounds.bounds)
|
|
144
|
+
|
|
145
|
+
for para in page.paragraphs: # lines joined by newlines, confidence averaged
|
|
146
|
+
print(para.confidence, para.text)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Filtering by Confidence
|
|
150
|
+
|
|
151
|
+
Drop the low-confidence noise with `filter()` - it hands back a new `OCRResult`
|
|
152
|
+
keeping only detections at or above the threshold:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
clean = page.filter(0.8) # only elements >= 0.8 confidence
|
|
156
|
+
print(clean.text)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Elements without a confidence score (Windows OCR doesn't report one) are kept by
|
|
160
|
+
default since they can't be judged - pass `drop_unknown=True` to drop them too.
|
|
161
|
+
|
|
125
162
|
### Detection Language
|
|
126
163
|
|
|
127
164
|
Pick a different recognition language, and inspect what the current platform
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "natocr"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "Native OCR library using platform-specific frameworks (macOS Vision, Windows Runtime OCR)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -64,6 +64,10 @@ Changelog = "https://github.com/alfredchiesa/natocr/blob/main/CHANGELOG.md"
|
|
|
64
64
|
where = ["."]
|
|
65
65
|
include = ["natocr*"]
|
|
66
66
|
|
|
67
|
+
[tool.setuptools.package-data]
|
|
68
|
+
# ship the pep 561 marker so downstream mypy/editors pick up our type hints
|
|
69
|
+
natocr = ["py.typed"]
|
|
70
|
+
|
|
67
71
|
[tool.pytest.ini_options]
|
|
68
72
|
testpaths = ["tests"]
|
|
69
73
|
python_files = ["test_*.py"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""tests for the data models"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from natocr.models import BoundingBox, OCRResult, TextElement, TextLine
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBoundingBox:
|
|
9
|
+
def test_bounds_returns_tuple(self):
|
|
10
|
+
box = BoundingBox(x=1.0, y=2.0, width=3.0, height=4.0)
|
|
11
|
+
assert box.bounds == (1.0, 2.0, 3.0, 4.0)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestTextElement:
|
|
15
|
+
def test_defaults_confidence_to_none(self):
|
|
16
|
+
elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
|
|
17
|
+
assert elem.confidence is None
|
|
18
|
+
|
|
19
|
+
def test_stores_confidence(self):
|
|
20
|
+
elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1), confidence=0.9)
|
|
21
|
+
assert elem.confidence == 0.9
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestOCRResult:
|
|
25
|
+
def test_post_init_defaults_elements_to_empty_list(self):
|
|
26
|
+
result = OCRResult(text="hi")
|
|
27
|
+
assert result.elements == []
|
|
28
|
+
|
|
29
|
+
def test_post_init_keeps_provided_elements(self):
|
|
30
|
+
elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
|
|
31
|
+
result = OCRResult(text="hi", elements=[elem])
|
|
32
|
+
assert result.elements == [elem]
|
|
33
|
+
|
|
34
|
+
def test_words_filters_whitespace_only(self):
|
|
35
|
+
good = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
|
|
36
|
+
blank = TextElement(text=" ", bounds=BoundingBox(0, 0, 1, 1))
|
|
37
|
+
result = OCRResult(text="hi", elements=[good, blank])
|
|
38
|
+
assert result.words == [good]
|
|
39
|
+
|
|
40
|
+
def test_lines_no_elements_with_text(self):
|
|
41
|
+
result = OCRResult(text="just text")
|
|
42
|
+
assert result.lines == ["just text"]
|
|
43
|
+
|
|
44
|
+
def test_lines_no_elements_no_text(self):
|
|
45
|
+
result = OCRResult(text="")
|
|
46
|
+
assert result.lines == []
|
|
47
|
+
|
|
48
|
+
def test_lines_groups_close_y_into_single_line(self):
|
|
49
|
+
a = TextElement(text="hello", bounds=BoundingBox(x=0, y=0, width=10, height=10))
|
|
50
|
+
b = TextElement(text="world", bounds=BoundingBox(x=20, y=1, width=10, height=10))
|
|
51
|
+
result = OCRResult(text="ignored", elements=[a, b])
|
|
52
|
+
assert result.lines == ["hello world"]
|
|
53
|
+
|
|
54
|
+
def test_lines_splits_distant_y_into_multiple_lines(self):
|
|
55
|
+
top = TextElement(text="top", bounds=BoundingBox(x=0, y=0, width=10, height=10))
|
|
56
|
+
bottom = TextElement(
|
|
57
|
+
text="bottom", bounds=BoundingBox(x=0, y=100, width=10, height=10)
|
|
58
|
+
)
|
|
59
|
+
# pass out of order to also exercise the (y, x) sort
|
|
60
|
+
result = OCRResult(text="ignored", elements=[bottom, top])
|
|
61
|
+
assert result.lines == ["top", "bottom"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestTextLines:
|
|
65
|
+
def test_aggregates_text_confidence_and_bounds_per_line(self):
|
|
66
|
+
a = TextElement(
|
|
67
|
+
text="hello", bounds=BoundingBox(0, 0, 10, 10), confidence=0.8
|
|
68
|
+
)
|
|
69
|
+
b = TextElement(
|
|
70
|
+
text="world", bounds=BoundingBox(20, 1, 10, 10), confidence=0.6
|
|
71
|
+
)
|
|
72
|
+
result = OCRResult(text="ignored", elements=[a, b])
|
|
73
|
+
lines = result.text_lines
|
|
74
|
+
assert len(lines) == 1
|
|
75
|
+
line = lines[0]
|
|
76
|
+
assert isinstance(line, TextLine)
|
|
77
|
+
assert line.text == "hello world"
|
|
78
|
+
assert line.confidence == pytest.approx(0.7) # mean of 0.8 and 0.6
|
|
79
|
+
# union box wraps both elements
|
|
80
|
+
assert line.bounds.bounds == (0, 0, 30, 11)
|
|
81
|
+
assert line.elements == [a, b]
|
|
82
|
+
|
|
83
|
+
def test_confidence_ignores_missing_scores(self):
|
|
84
|
+
scored = TextElement(text="a", bounds=BoundingBox(0, 0, 10, 10), confidence=0.9)
|
|
85
|
+
unscored = TextElement(text="b", bounds=BoundingBox(12, 0, 10, 10))
|
|
86
|
+
result = OCRResult(text="ignored", elements=[scored, unscored])
|
|
87
|
+
# only the scored element counts toward the mean
|
|
88
|
+
assert result.text_lines[0].confidence == pytest.approx(0.9)
|
|
89
|
+
|
|
90
|
+
def test_confidence_none_when_no_scores(self):
|
|
91
|
+
elem = TextElement(text="a", bounds=BoundingBox(0, 0, 10, 10))
|
|
92
|
+
result = OCRResult(text="ignored", elements=[elem])
|
|
93
|
+
assert result.text_lines[0].confidence is None
|
|
94
|
+
|
|
95
|
+
def test_empty_without_elements(self):
|
|
96
|
+
assert OCRResult(text="just text").text_lines == []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestParagraphs:
|
|
100
|
+
def test_groups_lines_by_vertical_gap(self):
|
|
101
|
+
# two stacked lines, then a big gap, then a third line
|
|
102
|
+
l1 = TextElement(text="one", bounds=BoundingBox(0, 0, 10, 10), confidence=0.9)
|
|
103
|
+
l2 = TextElement(text="two", bounds=BoundingBox(0, 12, 10, 10), confidence=0.7)
|
|
104
|
+
l3 = TextElement(text="far", bounds=BoundingBox(0, 100, 10, 10), confidence=0.5)
|
|
105
|
+
result = OCRResult(text="ignored", elements=[l1, l2, l3])
|
|
106
|
+
paras = result.paragraphs
|
|
107
|
+
assert len(paras) == 2
|
|
108
|
+
# first paragraph stacks the two close lines, newline-joined
|
|
109
|
+
assert paras[0].text == "one\ntwo"
|
|
110
|
+
assert paras[0].confidence == pytest.approx(0.8) # mean of 0.9 and 0.7
|
|
111
|
+
assert paras[1].text == "far"
|
|
112
|
+
|
|
113
|
+
def test_empty_without_elements(self):
|
|
114
|
+
assert OCRResult(text="just text").paragraphs == []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestFilter:
|
|
118
|
+
def _result(self):
|
|
119
|
+
high = TextElement(text="hi", bounds=BoundingBox(0, 0, 10, 10), confidence=0.9)
|
|
120
|
+
low = TextElement(text="lo", bounds=BoundingBox(0, 20, 10, 10), confidence=0.4)
|
|
121
|
+
unknown = TextElement(text="??", bounds=BoundingBox(0, 40, 10, 10))
|
|
122
|
+
return OCRResult(text="ignored", elements=[high, low, unknown]), high, unknown
|
|
123
|
+
|
|
124
|
+
def test_drops_below_threshold_keeps_unknown_by_default(self):
|
|
125
|
+
result, high, unknown = self._result()
|
|
126
|
+
out = result.filter(0.5)
|
|
127
|
+
# low (0.4) drops; high (0.9) and the unscored one stay
|
|
128
|
+
assert out.elements == [high, unknown]
|
|
129
|
+
assert out.text == "hi ??"
|
|
130
|
+
assert out.confidence == pytest.approx(0.9) # unknown ignored in the mean
|
|
131
|
+
|
|
132
|
+
def test_drop_unknown_removes_unscored_elements(self):
|
|
133
|
+
result, high, _ = self._result()
|
|
134
|
+
out = result.filter(0.5, drop_unknown=True)
|
|
135
|
+
assert out.elements == [high]
|
|
136
|
+
|
|
137
|
+
def test_returns_new_result_without_mutating(self):
|
|
138
|
+
result, _, _ = self._result()
|
|
139
|
+
out = result.filter(0.5)
|
|
140
|
+
assert out is not result
|
|
141
|
+
assert len(result.elements) == 3 # original untouched
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""tests for the package's public surface"""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import re
|
|
4
5
|
|
|
5
6
|
import natocr
|
|
@@ -12,5 +13,11 @@ def test_version_exposed():
|
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def test_public_exports():
|
|
15
|
-
for name in ("OCR", "OCRResult", "TextElement", "BoundingBox"):
|
|
16
|
+
for name in ("OCR", "OCRResult", "TextElement", "TextLine", "BoundingBox"):
|
|
16
17
|
assert hasattr(natocr, name)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_ships_py_typed_marker():
|
|
21
|
+
# pep 561 marker so downstream type checkers trust our hints
|
|
22
|
+
marker = os.path.join(os.path.dirname(natocr.__file__), "py.typed")
|
|
23
|
+
assert os.path.isfile(marker)
|
natocr-2.0.0/natocr/models.py
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
data models for ocr results
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import List, Optional, Tuple
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@dataclass
|
|
10
|
-
class BoundingBox:
|
|
11
|
-
"""Pixel-space bounding box for a piece of detected text.
|
|
12
|
-
|
|
13
|
-
The origin is the top-left of the image, with ``y`` growing downward.
|
|
14
|
-
|
|
15
|
-
Attributes:
|
|
16
|
-
x: left edge, in pixels.
|
|
17
|
-
y: top edge, in pixels.
|
|
18
|
-
width: box width, in pixels.
|
|
19
|
-
height: box height, in pixels.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
x: float
|
|
23
|
-
y: float
|
|
24
|
-
width: float
|
|
25
|
-
height: float
|
|
26
|
-
|
|
27
|
-
@property
|
|
28
|
-
def bounds(self) -> Tuple[float, float, float, float]:
|
|
29
|
-
"""The box as an ``(x, y, width, height)`` tuple."""
|
|
30
|
-
return (self.x, self.y, self.width, self.height)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@dataclass
|
|
34
|
-
class TextElement:
|
|
35
|
-
"""A single detected piece of text with its location.
|
|
36
|
-
|
|
37
|
-
Attributes:
|
|
38
|
-
text: the recognized string.
|
|
39
|
-
bounds: where it was found in the image.
|
|
40
|
-
confidence: recognition confidence in ``0.0..1.0``, or ``None`` when the
|
|
41
|
-
backend doesn't report one (Windows OCR doesn't).
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
text: str
|
|
45
|
-
bounds: BoundingBox
|
|
46
|
-
confidence: Optional[float] = None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@dataclass
|
|
50
|
-
class OCRResult:
|
|
51
|
-
"""Everything an OCR pass found in one image.
|
|
52
|
-
|
|
53
|
-
Attributes:
|
|
54
|
-
text: all detected text joined into a single string.
|
|
55
|
-
confidence: average confidence across detections, or ``None`` if the
|
|
56
|
-
backend doesn't report confidence.
|
|
57
|
-
elements: per-detection breakdown with text, bounds, and confidence.
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
text: str
|
|
61
|
-
confidence: Optional[float] = None
|
|
62
|
-
elements: List[TextElement] = None
|
|
63
|
-
|
|
64
|
-
def __post_init__(self):
|
|
65
|
-
# default the mutable elements list when none was passed
|
|
66
|
-
if self.elements is None:
|
|
67
|
-
self.elements = []
|
|
68
|
-
|
|
69
|
-
@property
|
|
70
|
-
def words(self) -> List[TextElement]:
|
|
71
|
-
"""The elements that contain non-whitespace text."""
|
|
72
|
-
return [elem for elem in self.elements if elem.text.strip()]
|
|
73
|
-
|
|
74
|
-
@property
|
|
75
|
-
def lines(self) -> List[str]:
|
|
76
|
-
"""Detected text grouped into lines by vertical position.
|
|
77
|
-
|
|
78
|
-
Elements whose ``y`` are close together are treated as one line and
|
|
79
|
-
joined left-to-right. Falls back to ``[text]`` (or ``[]``) when there
|
|
80
|
-
are no elements.
|
|
81
|
-
"""
|
|
82
|
-
if not self.elements:
|
|
83
|
-
return [self.text] if self.text else []
|
|
84
|
-
|
|
85
|
-
# group elements by approximate y-coordinate for line detection
|
|
86
|
-
lines = []
|
|
87
|
-
current_line = []
|
|
88
|
-
current_y = None
|
|
89
|
-
|
|
90
|
-
for elem in sorted(self.elements, key=lambda e: (e.bounds.y, e.bounds.x)):
|
|
91
|
-
if (
|
|
92
|
-
current_y is None
|
|
93
|
-
or abs(elem.bounds.y - current_y) < elem.bounds.height * 0.5
|
|
94
|
-
):
|
|
95
|
-
current_line.append(elem)
|
|
96
|
-
current_y = elem.bounds.y
|
|
97
|
-
else:
|
|
98
|
-
if current_line:
|
|
99
|
-
line_text = " ".join(elem.text for elem in current_line)
|
|
100
|
-
lines.append(line_text)
|
|
101
|
-
current_line = [elem]
|
|
102
|
-
current_y = elem.bounds.y
|
|
103
|
-
|
|
104
|
-
if current_line:
|
|
105
|
-
line_text = " ".join(elem.text for elem in current_line)
|
|
106
|
-
lines.append(line_text)
|
|
107
|
-
|
|
108
|
-
return lines
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
"""tests for the data models"""
|
|
2
|
-
|
|
3
|
-
from natocr.models import BoundingBox, OCRResult, TextElement
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class TestBoundingBox:
|
|
7
|
-
def test_bounds_returns_tuple(self):
|
|
8
|
-
box = BoundingBox(x=1.0, y=2.0, width=3.0, height=4.0)
|
|
9
|
-
assert box.bounds == (1.0, 2.0, 3.0, 4.0)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TestTextElement:
|
|
13
|
-
def test_defaults_confidence_to_none(self):
|
|
14
|
-
elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
|
|
15
|
-
assert elem.confidence is None
|
|
16
|
-
|
|
17
|
-
def test_stores_confidence(self):
|
|
18
|
-
elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1), confidence=0.9)
|
|
19
|
-
assert elem.confidence == 0.9
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class TestOCRResult:
|
|
23
|
-
def test_post_init_defaults_elements_to_empty_list(self):
|
|
24
|
-
result = OCRResult(text="hi")
|
|
25
|
-
assert result.elements == []
|
|
26
|
-
|
|
27
|
-
def test_post_init_keeps_provided_elements(self):
|
|
28
|
-
elem = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
|
|
29
|
-
result = OCRResult(text="hi", elements=[elem])
|
|
30
|
-
assert result.elements == [elem]
|
|
31
|
-
|
|
32
|
-
def test_words_filters_whitespace_only(self):
|
|
33
|
-
good = TextElement(text="hi", bounds=BoundingBox(0, 0, 1, 1))
|
|
34
|
-
blank = TextElement(text=" ", bounds=BoundingBox(0, 0, 1, 1))
|
|
35
|
-
result = OCRResult(text="hi", elements=[good, blank])
|
|
36
|
-
assert result.words == [good]
|
|
37
|
-
|
|
38
|
-
def test_lines_no_elements_with_text(self):
|
|
39
|
-
result = OCRResult(text="just text")
|
|
40
|
-
assert result.lines == ["just text"]
|
|
41
|
-
|
|
42
|
-
def test_lines_no_elements_no_text(self):
|
|
43
|
-
result = OCRResult(text="")
|
|
44
|
-
assert result.lines == []
|
|
45
|
-
|
|
46
|
-
def test_lines_groups_close_y_into_single_line(self):
|
|
47
|
-
a = TextElement(text="hello", bounds=BoundingBox(x=0, y=0, width=10, height=10))
|
|
48
|
-
b = TextElement(text="world", bounds=BoundingBox(x=20, y=1, width=10, height=10))
|
|
49
|
-
result = OCRResult(text="ignored", elements=[a, b])
|
|
50
|
-
assert result.lines == ["hello world"]
|
|
51
|
-
|
|
52
|
-
def test_lines_splits_distant_y_into_multiple_lines(self):
|
|
53
|
-
top = TextElement(text="top", bounds=BoundingBox(x=0, y=0, width=10, height=10))
|
|
54
|
-
bottom = TextElement(
|
|
55
|
-
text="bottom", bounds=BoundingBox(x=0, y=100, width=10, height=10)
|
|
56
|
-
)
|
|
57
|
-
# pass out of order to also exercise the (y, x) sort
|
|
58
|
-
result = OCRResult(text="ignored", elements=[bottom, top])
|
|
59
|
-
assert result.lines == ["top", "bottom"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|