structx 0.6.3__tar.gz → 0.6.5__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.
- {structx-0.6.3/structx.egg-info → structx-0.6.5}/PKG-INFO +33 -2
- {structx-0.6.3 → structx-0.6.5}/README.md +26 -1
- {structx-0.6.3 → structx-0.6.5}/pyproject.toml +16 -2
- {structx-0.6.3 → structx-0.6.5}/structx/__init__.py +1 -1
- structx-0.6.5/structx/measurement.py +506 -0
- structx-0.6.5/structx/utils/measurement_ocr.py +138 -0
- {structx-0.6.3 → structx-0.6.5/structx.egg-info}/PKG-INFO +33 -2
- {structx-0.6.3 → structx-0.6.5}/structx.egg-info/SOURCES.txt +3 -0
- {structx-0.6.3 → structx-0.6.5}/structx.egg-info/requires.txt +8 -0
- structx-0.6.5/tests/test_measurement.py +602 -0
- {structx-0.6.3 → structx-0.6.5}/LICENSE +0 -0
- {structx-0.6.3 → structx-0.6.5}/setup.cfg +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/core/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/core/config.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/core/exceptions.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/core/input.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/core/models.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/core/type_system.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/core/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/core/llm_core.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/engines/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/engines/extraction_engine.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/extractor.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/generator.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/batch_processor.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/content_analyzer.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/input_processor.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/model_operations.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/extraction/result_manager.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/schema.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/utils/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/utils/file_reader.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/utils/helpers.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/utils/prompts.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/utils/types.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx/utils/usage.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx.egg-info/dependency_links.txt +0 -0
- {structx-0.6.3 → structx-0.6.5}/structx.egg-info/top_level.txt +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_config_and_model_utils.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_content_analyzer.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_document_conversion_integration.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_extraction_engine.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_extractor_async.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_extractor_flow.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_file_reader.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_input_and_batch_processors.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_live_llm.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_llm_configuration.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_result_manager_and_helpers.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_schema_contract.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_type_system_and_planning.py +0 -0
- {structx-0.6.3 → structx-0.6.5}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: structx
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.5
|
|
4
4
|
Summary: Structured data extraction from text using LLMs and dynamic model generation
|
|
5
5
|
Author-email: blacksuan19 <py@aolabs.dev>
|
|
6
6
|
Classifier: Development Status :: 5 - Production/Stable
|
|
@@ -49,6 +49,12 @@ Requires-Dist: docling-slim[convert-core,format-docx,format-html,format-latex,fo
|
|
|
49
49
|
Requires-Dist: torch>=2.13.0; extra == "docs"
|
|
50
50
|
Requires-Dist: torchvision>=0.28.0; extra == "docs"
|
|
51
51
|
Requires-Dist: weasyprint>=69.0; extra == "docs"
|
|
52
|
+
Provides-Extra: measurement
|
|
53
|
+
Requires-Dist: pypdfium2>=5.12.1; extra == "measurement"
|
|
54
|
+
Provides-Extra: measurement-ocr
|
|
55
|
+
Requires-Dist: pypdfium2>=5.12.1; extra == "measurement-ocr"
|
|
56
|
+
Requires-Dist: rapidocr>=3.9.0; extra == "measurement-ocr"
|
|
57
|
+
Requires-Dist: onnxruntime>=1.22.0; extra == "measurement-ocr"
|
|
52
58
|
Provides-Extra: mkdocs
|
|
53
59
|
Requires-Dist: mkdocs>=1.6.1; extra == "mkdocs"
|
|
54
60
|
Requires-Dist: mkdocs-material>=9.7.6; extra == "mkdocs"
|
|
@@ -64,7 +70,7 @@ Advanced structured data extraction from any document using LLMs with multimodal
|
|
|
64
70
|
support.
|
|
65
71
|
|
|
66
72
|
[](https://structx.aolabs.dev "Documentation")
|
|
67
|
-
[](https://pypi.org/project/structx "Package")
|
|
68
74
|
[](# "Build with GitHub Actions")
|
|
69
75
|
|
|
70
76
|
See the [project roadmap](docs/ROADMAP.md) for planned portable business rules
|
|
@@ -140,12 +146,21 @@ non-PDF document formats:
|
|
|
140
146
|
pip install "structx[docs]"
|
|
141
147
|
```
|
|
142
148
|
|
|
149
|
+
For estimating PDF page and text counts before extraction, without running an
|
|
150
|
+
extraction:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install "structx[measurement]" # embedded text only
|
|
154
|
+
pip install "structx[measurement-ocr]" # adds local OCR for scanned pages
|
|
155
|
+
```
|
|
156
|
+
|
|
143
157
|
### 🔧 What The Package Provides
|
|
144
158
|
|
|
145
159
|
- Structured readers for CSV, Excel, JSON, Parquet, and Feather
|
|
146
160
|
- Instructor multimodal vision support
|
|
147
161
|
- Optional Docling document parsing with CPU-only PyTorch resolution for uv on Linux
|
|
148
162
|
- Optional WeasyPrint PDF rendering for non-PDF document formats
|
|
163
|
+
- Optional standalone document measurement, separate from the extraction path
|
|
149
164
|
|
|
150
165
|
## Quick Start
|
|
151
166
|
|
|
@@ -193,6 +208,22 @@ result = extractor.extract(
|
|
|
193
208
|
)
|
|
194
209
|
```
|
|
195
210
|
|
|
211
|
+
### 📏 Document Measurement Before Extraction
|
|
212
|
+
|
|
213
|
+
Install `structx[measurement]` to size a PDF without extracting it. This path is
|
|
214
|
+
independent of extraction: it never converts or replaces the input file.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from structx.measurement import DocumentMeasurer
|
|
218
|
+
|
|
219
|
+
measurement = DocumentMeasurer(ocr_mode="auto").measure(
|
|
220
|
+
"scripts/example_input/S0305SampleInvoice.pdf"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Estimates, plus whether every page could actually be measured
|
|
224
|
+
print(measurement.page_count, measurement.character_count, measurement.status)
|
|
225
|
+
```
|
|
226
|
+
|
|
196
227
|
### 📊 Token Usage Monitoring
|
|
197
228
|
|
|
198
229
|
```python
|
|
@@ -4,7 +4,7 @@ Advanced structured data extraction from any document using LLMs with multimodal
|
|
|
4
4
|
support.
|
|
5
5
|
|
|
6
6
|
[](https://structx.aolabs.dev "Documentation")
|
|
7
|
-
[](https://pypi.org/project/structx "Package")
|
|
8
8
|
[](# "Build with GitHub Actions")
|
|
9
9
|
|
|
10
10
|
See the [project roadmap](docs/ROADMAP.md) for planned portable business rules
|
|
@@ -80,12 +80,21 @@ non-PDF document formats:
|
|
|
80
80
|
pip install "structx[docs]"
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
For estimating PDF page and text counts before extraction, without running an
|
|
84
|
+
extraction:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install "structx[measurement]" # embedded text only
|
|
88
|
+
pip install "structx[measurement-ocr]" # adds local OCR for scanned pages
|
|
89
|
+
```
|
|
90
|
+
|
|
83
91
|
### 🔧 What The Package Provides
|
|
84
92
|
|
|
85
93
|
- Structured readers for CSV, Excel, JSON, Parquet, and Feather
|
|
86
94
|
- Instructor multimodal vision support
|
|
87
95
|
- Optional Docling document parsing with CPU-only PyTorch resolution for uv on Linux
|
|
88
96
|
- Optional WeasyPrint PDF rendering for non-PDF document formats
|
|
97
|
+
- Optional standalone document measurement, separate from the extraction path
|
|
89
98
|
|
|
90
99
|
## Quick Start
|
|
91
100
|
|
|
@@ -133,6 +142,22 @@ result = extractor.extract(
|
|
|
133
142
|
)
|
|
134
143
|
```
|
|
135
144
|
|
|
145
|
+
### 📏 Document Measurement Before Extraction
|
|
146
|
+
|
|
147
|
+
Install `structx[measurement]` to size a PDF without extracting it. This path is
|
|
148
|
+
independent of extraction: it never converts or replaces the input file.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from structx.measurement import DocumentMeasurer
|
|
152
|
+
|
|
153
|
+
measurement = DocumentMeasurer(ocr_mode="auto").measure(
|
|
154
|
+
"scripts/example_input/S0305SampleInvoice.pdf"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Estimates, plus whether every page could actually be measured
|
|
158
|
+
print(measurement.page_count, measurement.character_count, measurement.status)
|
|
159
|
+
```
|
|
160
|
+
|
|
136
161
|
### 📊 Token Usage Monitoring
|
|
137
162
|
|
|
138
163
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "structx"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.5"
|
|
8
8
|
description = "Structured data extraction from text using LLMs and dynamic model generation"
|
|
9
9
|
authors = [{ name = "blacksuan19", email = "py@aolabs.dev" }]
|
|
10
10
|
license-files = ["LICENSE"]
|
|
@@ -68,6 +68,20 @@ docs = [
|
|
|
68
68
|
"weasyprint>=69.0",
|
|
69
69
|
]
|
|
70
70
|
|
|
71
|
+
# Optional standalone document measurement (structx.measurement). Independent
|
|
72
|
+
# of extraction: it estimates page and text counts for callers that need a
|
|
73
|
+
# size estimate before running an extraction.
|
|
74
|
+
measurement = [
|
|
75
|
+
"pypdfium2>=5.12.1",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# Local OCR for measuring pages that have no embedded text layer.
|
|
79
|
+
measurement-ocr = [
|
|
80
|
+
"pypdfium2>=5.12.1",
|
|
81
|
+
"rapidocr>=3.9.0",
|
|
82
|
+
"onnxruntime>=1.22.0",
|
|
83
|
+
]
|
|
84
|
+
|
|
71
85
|
# Documentation site tooling.
|
|
72
86
|
mkdocs = [
|
|
73
87
|
"mkdocs>=1.6.1",
|
|
@@ -92,7 +106,7 @@ torchvision = [
|
|
|
92
106
|
]
|
|
93
107
|
|
|
94
108
|
[tool.bumpver]
|
|
95
|
-
current_version = "0.6.
|
|
109
|
+
current_version = "0.6.5"
|
|
96
110
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
97
111
|
commit_message = "chore(release): bump version {old_version} -> {new_version}"
|
|
98
112
|
commit = true
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Standalone document measurement.
|
|
2
|
+
|
|
3
|
+
This module estimates how much content a PDF contains. It exists for callers
|
|
4
|
+
that need page and text-length estimates *before* extraction, such as usage
|
|
5
|
+
quoting in a hosted product.
|
|
6
|
+
|
|
7
|
+
It is deliberately independent of the extraction pipeline:
|
|
8
|
+
|
|
9
|
+
- Extraction never calls this module.
|
|
10
|
+
- Measuring a document never rewrites, converts, or replaces the input file.
|
|
11
|
+
- OCR here is local image-to-text recognition, never a model request.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
```python
|
|
15
|
+
from structx.measurement import DocumentMeasurer
|
|
16
|
+
|
|
17
|
+
measurer = DocumentMeasurer(ocr_mode="auto")
|
|
18
|
+
measurement = measurer.measure("invoice.pdf")
|
|
19
|
+
print(measurement.page_count, measurement.character_count, measurement.status)
|
|
20
|
+
```
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from math import ceil, sqrt
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable, Optional, Tuple, Union
|
|
29
|
+
|
|
30
|
+
from structx.core.exceptions import ConfigurationError, FileError
|
|
31
|
+
from structx.utils.measurement_ocr import (
|
|
32
|
+
OcrEngineUnavailableError,
|
|
33
|
+
create_default_ocr_reader,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
ESTIMATOR_REVISION = "1"
|
|
37
|
+
"""Revision of the counting algorithm.
|
|
38
|
+
|
|
39
|
+
This identifies how counts were produced. It is not a promise that optional OCR
|
|
40
|
+
models or their versions are frozen.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
OCR_MODES: Tuple[str, ...] = ("never", "auto", "always")
|
|
44
|
+
|
|
45
|
+
MEASUREMENT_BYTES_LIMIT = 50 * 1024 * 1024
|
|
46
|
+
MEASUREMENT_PAGE_LIMIT = 500
|
|
47
|
+
MEASUREMENT_RENDER_DPI = 150.0
|
|
48
|
+
MEASUREMENT_RENDER_PIXEL_LIMIT = 12_000_000
|
|
49
|
+
|
|
50
|
+
# PDFium is not thread-safe, so all measurement work through this module is
|
|
51
|
+
# serialized. Run heavy OCR workloads in separate processes instead.
|
|
52
|
+
_PDFIUM_LOCK = threading.Lock()
|
|
53
|
+
|
|
54
|
+
_PDF_POINTS_PER_INCH = 72.0
|
|
55
|
+
_RENDER_SCALE_ATTEMPTS = 8
|
|
56
|
+
_RENDER_SCALE_MARGIN = 0.999
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class PageMeasurement:
|
|
61
|
+
"""Measured content of one page.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
page_number: One-based page position in the source document.
|
|
65
|
+
character_count: Unicode code points observed on the page.
|
|
66
|
+
method: How the count was produced: ``native``, ``ocr``, or ``none``.
|
|
67
|
+
status: ``complete`` when the page was measured, ``partial`` when the
|
|
68
|
+
count is known to be incomplete or unavailable.
|
|
69
|
+
error_code: Stable reason for a partial page, otherwise ``None``.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
page_number: int
|
|
73
|
+
character_count: int
|
|
74
|
+
method: str
|
|
75
|
+
status: str
|
|
76
|
+
error_code: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class DocumentMeasurement:
|
|
81
|
+
"""Measured content of one document.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
pages: Page measurements in source order.
|
|
85
|
+
ocr_mode: OCR mode used for this measurement.
|
|
86
|
+
estimator_revision: Revision of the counting algorithm.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
pages: Tuple[PageMeasurement, ...] = field(default_factory=tuple)
|
|
90
|
+
ocr_mode: str = "never"
|
|
91
|
+
estimator_revision: str = ESTIMATOR_REVISION
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def page_count(self) -> int:
|
|
95
|
+
"""Number of pages in the measured document."""
|
|
96
|
+
return len(self.pages)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def character_count(self) -> int:
|
|
100
|
+
"""Total characters observed across measured pages."""
|
|
101
|
+
return sum(page.character_count for page in self.pages)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def status(self) -> str:
|
|
105
|
+
"""``complete`` only when every page of a non-empty document was measured."""
|
|
106
|
+
if not self.pages:
|
|
107
|
+
return "partial"
|
|
108
|
+
if all(page.status == "complete" for page in self.pages):
|
|
109
|
+
return "complete"
|
|
110
|
+
return "partial"
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def error_codes(self) -> Tuple[str, ...]:
|
|
114
|
+
"""Distinct partial-page reasons, in first-seen order."""
|
|
115
|
+
codes: list = []
|
|
116
|
+
for page in self.pages:
|
|
117
|
+
if page.error_code and page.error_code not in codes:
|
|
118
|
+
codes.append(page.error_code)
|
|
119
|
+
return tuple(codes)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class _PagePlan:
|
|
124
|
+
"""Work-in-progress state for one page, used between rendering and OCR."""
|
|
125
|
+
|
|
126
|
+
page_number: int
|
|
127
|
+
native_text: str = ""
|
|
128
|
+
native_failed: bool = False
|
|
129
|
+
image: Any = None
|
|
130
|
+
measurement: Optional[PageMeasurement] = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _positive_int(value: Any, name: str) -> int:
|
|
134
|
+
"""Validate a positive integer setting."""
|
|
135
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
136
|
+
raise ConfigurationError(f"{name} must be a positive integer")
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _positive_real(value: Any, name: str) -> float:
|
|
141
|
+
"""Validate a positive finite numeric setting."""
|
|
142
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
143
|
+
raise ConfigurationError(f"{name} must be a positive number")
|
|
144
|
+
number = float(value)
|
|
145
|
+
if number <= 0 or number != number or number in (float("inf"), float("-inf")):
|
|
146
|
+
raise ConfigurationError(f"{name} must be a positive finite number")
|
|
147
|
+
return number
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalized(text: str) -> str:
|
|
151
|
+
"""Normalize line endings so counts do not depend on the source platform."""
|
|
152
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _import_pdfium():
|
|
156
|
+
"""Import the optional PDF backend used for measurement."""
|
|
157
|
+
try:
|
|
158
|
+
import pypdfium2
|
|
159
|
+
except Exception as error: # pragma: no cover - import environment
|
|
160
|
+
raise ConfigurationError(
|
|
161
|
+
"Document measurement requires pypdfium2. " "Install structx[measurement]."
|
|
162
|
+
) from error
|
|
163
|
+
return pypdfium2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DocumentMeasurer:
|
|
167
|
+
"""Estimate page and character counts for PDF documents.
|
|
168
|
+
|
|
169
|
+
The measurer is reusable: one instance can measure many documents and
|
|
170
|
+
reuses a single OCR reader when OCR is enabled. Measurement is synchronous
|
|
171
|
+
and serialized internally.
|
|
172
|
+
|
|
173
|
+
Attributes:
|
|
174
|
+
ocr_mode: ``never`` (default) uses embedded text only. ``auto`` also
|
|
175
|
+
runs OCR for pages with no usable embedded text. ``always`` reads
|
|
176
|
+
every page with OCR instead of its embedded text.
|
|
177
|
+
max_pages: Largest document accepted, in pages.
|
|
178
|
+
max_input_bytes: Largest input accepted, in bytes.
|
|
179
|
+
render_dpi: Resolution used when rendering a page for OCR.
|
|
180
|
+
max_render_pixels: Upper bound on rendered pixels per page. Larger
|
|
181
|
+
pages are rendered at a reduced scale rather than skipped.
|
|
182
|
+
ocr_workers: Pages recognized at once. The default of 1 keeps OCR
|
|
183
|
+
serial. Higher values give each worker thread its own engine and
|
|
184
|
+
bound how many page images are held in memory at once.
|
|
185
|
+
ocr_engine_params: Parameters for the built-in OCR engine. Ignored when
|
|
186
|
+
``ocr_reader`` is supplied.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
ocr_mode: str = "never",
|
|
193
|
+
ocr_reader: Optional[Callable[[Any], str]] = None,
|
|
194
|
+
ocr_workers: int = 1,
|
|
195
|
+
ocr_engine_params: Optional[dict] = None,
|
|
196
|
+
max_pages: int = MEASUREMENT_PAGE_LIMIT,
|
|
197
|
+
max_input_bytes: int = MEASUREMENT_BYTES_LIMIT,
|
|
198
|
+
render_dpi: float = MEASUREMENT_RENDER_DPI,
|
|
199
|
+
max_render_pixels: int = MEASUREMENT_RENDER_PIXEL_LIMIT,
|
|
200
|
+
) -> None:
|
|
201
|
+
if ocr_mode not in OCR_MODES:
|
|
202
|
+
raise ConfigurationError(f"ocr_mode must be one of {', '.join(OCR_MODES)}")
|
|
203
|
+
if ocr_reader is not None and not callable(ocr_reader):
|
|
204
|
+
raise ConfigurationError("ocr_reader must be callable")
|
|
205
|
+
if ocr_engine_params is not None and not isinstance(ocr_engine_params, dict):
|
|
206
|
+
raise ConfigurationError("ocr_engine_params must be a dict")
|
|
207
|
+
|
|
208
|
+
self.ocr_mode = ocr_mode
|
|
209
|
+
self.ocr_workers = _positive_int(ocr_workers, "ocr_workers")
|
|
210
|
+
self.ocr_engine_params = dict(ocr_engine_params or {})
|
|
211
|
+
self.max_pages = _positive_int(max_pages, "max_pages")
|
|
212
|
+
self.max_input_bytes = _positive_int(max_input_bytes, "max_input_bytes")
|
|
213
|
+
self.render_dpi = _positive_real(render_dpi, "render_dpi")
|
|
214
|
+
self.max_render_pixels = _positive_int(max_render_pixels, "max_render_pixels")
|
|
215
|
+
self._ocr_reader = ocr_reader
|
|
216
|
+
self._ocr_pool: Optional[ThreadPoolExecutor] = None
|
|
217
|
+
self._reader_lock = threading.Lock()
|
|
218
|
+
|
|
219
|
+
def measure(self, file_path: Union[str, Path]) -> DocumentMeasurement:
|
|
220
|
+
"""Measure one PDF document.
|
|
221
|
+
|
|
222
|
+
PDF reading and rendering are serialized because the PDF backend is not
|
|
223
|
+
thread-safe. OCR runs outside that lock, so a slow scanned document does
|
|
224
|
+
not block other documents measured at the same time.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
file_path: Path to an existing PDF file.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Page and character estimates with per-page completeness.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
FileError: If the file is missing, empty, not a readable PDF, or
|
|
234
|
+
exceeds the configured size or page limits.
|
|
235
|
+
ConfigurationError: If the optional PDF backend is unavailable.
|
|
236
|
+
"""
|
|
237
|
+
path = self._validated_path(file_path)
|
|
238
|
+
pdfium = _import_pdfium()
|
|
239
|
+
pages = self._measure_pages(pdfium, path)
|
|
240
|
+
return DocumentMeasurement(
|
|
241
|
+
pages=tuple(pages),
|
|
242
|
+
ocr_mode=self.ocr_mode,
|
|
243
|
+
estimator_revision=ESTIMATOR_REVISION,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _validated_path(self, file_path: Union[str, Path]) -> Path:
|
|
247
|
+
"""Reject inputs this API cannot measure before opening them."""
|
|
248
|
+
path = Path(file_path)
|
|
249
|
+
if not path.exists():
|
|
250
|
+
raise FileError(f"File not found: {path}")
|
|
251
|
+
if not path.is_file():
|
|
252
|
+
raise FileError(f"Path is not a file: {path}")
|
|
253
|
+
|
|
254
|
+
size = path.stat().st_size
|
|
255
|
+
if size == 0:
|
|
256
|
+
raise FileError(f"File is empty: {path}")
|
|
257
|
+
if size > self.max_input_bytes:
|
|
258
|
+
raise FileError(
|
|
259
|
+
f"File exceeds the {self.max_input_bytes} byte measurement limit: "
|
|
260
|
+
f"{path}"
|
|
261
|
+
)
|
|
262
|
+
if path.suffix.lower() != ".pdf":
|
|
263
|
+
raise FileError(
|
|
264
|
+
f"Document measurement supports PDF input only: {path.suffix}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
with path.open("rb") as pdf_file:
|
|
268
|
+
header = pdf_file.read(1024)
|
|
269
|
+
if b"%PDF-" not in header:
|
|
270
|
+
raise FileError(f"Invalid PDF file: {path}")
|
|
271
|
+
return path
|
|
272
|
+
|
|
273
|
+
def _measure_pages(self, pdfium: Any, path: Path) -> list:
|
|
274
|
+
"""Measure every page in order, in batches bounded by the worker count."""
|
|
275
|
+
with _PDFIUM_LOCK:
|
|
276
|
+
try:
|
|
277
|
+
document = pdfium.PdfDocument(path)
|
|
278
|
+
except Exception as error:
|
|
279
|
+
raise FileError(
|
|
280
|
+
f"Could not open PDF for measurement: {path}"
|
|
281
|
+
) from error
|
|
282
|
+
try:
|
|
283
|
+
if _is_encrypted(pdfium, document):
|
|
284
|
+
raise FileError(f"Encrypted PDFs cannot be measured: {path}")
|
|
285
|
+
page_count = len(document)
|
|
286
|
+
if page_count < 1:
|
|
287
|
+
raise FileError(f"The PDF contains no pages: {path}")
|
|
288
|
+
if page_count > self.max_pages:
|
|
289
|
+
raise FileError(
|
|
290
|
+
f"PDF exceeds the {self.max_pages} page measurement "
|
|
291
|
+
f"limit: {path}"
|
|
292
|
+
)
|
|
293
|
+
except FileError:
|
|
294
|
+
document.close()
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
measurements: list = []
|
|
298
|
+
try:
|
|
299
|
+
# Only the pages of one batch are rendered at a time, so a long
|
|
300
|
+
# document does not hold every page image in memory at once.
|
|
301
|
+
for start in range(0, page_count, self.ocr_workers):
|
|
302
|
+
end = min(start + self.ocr_workers, page_count)
|
|
303
|
+
with _PDFIUM_LOCK:
|
|
304
|
+
plans = [self._prepare_page(document, i) for i in range(start, end)]
|
|
305
|
+
measurements.extend(self._recognize_batch(plans))
|
|
306
|
+
finally:
|
|
307
|
+
with _PDFIUM_LOCK:
|
|
308
|
+
document.close()
|
|
309
|
+
return measurements
|
|
310
|
+
|
|
311
|
+
def _prepare_page(self, document: Any, index: int) -> _PagePlan:
|
|
312
|
+
"""Read a page's text layer and render it only when OCR is needed.
|
|
313
|
+
|
|
314
|
+
This runs under the PDF lock. Recognition itself happens afterwards.
|
|
315
|
+
"""
|
|
316
|
+
page_number = index + 1
|
|
317
|
+
try:
|
|
318
|
+
page = document[index]
|
|
319
|
+
except Exception:
|
|
320
|
+
return _PagePlan(
|
|
321
|
+
page_number,
|
|
322
|
+
measurement=PageMeasurement(
|
|
323
|
+
page_number, 0, "none", "partial", "page_load_failed"
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
native_text, native_failed = _native_text(page)
|
|
329
|
+
plan = _PagePlan(
|
|
330
|
+
page_number, native_text=native_text, native_failed=native_failed
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if self.ocr_mode == "never":
|
|
334
|
+
plan.measurement = self._without_ocr(plan)
|
|
335
|
+
return plan
|
|
336
|
+
if self.ocr_mode == "auto" and not native_failed and native_text.strip():
|
|
337
|
+
plan.measurement = PageMeasurement(
|
|
338
|
+
page_number, len(native_text), "native", "complete"
|
|
339
|
+
)
|
|
340
|
+
return plan
|
|
341
|
+
if self.ocr_mode == "auto" and native_failed:
|
|
342
|
+
# A failed text layer says nothing about the page, so the
|
|
343
|
+
# native text is not reused as a fallback count.
|
|
344
|
+
plan.native_text = ""
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
plan.image = self._rendered_image(page)
|
|
348
|
+
except Exception:
|
|
349
|
+
plan.measurement = self._ocr_unavailable(plan, "page_render_failed")
|
|
350
|
+
return plan
|
|
351
|
+
finally:
|
|
352
|
+
page.close()
|
|
353
|
+
|
|
354
|
+
def _without_ocr(self, plan: _PagePlan) -> PageMeasurement:
|
|
355
|
+
"""Resolve a page that will not be sent to OCR."""
|
|
356
|
+
if plan.native_failed:
|
|
357
|
+
return PageMeasurement(
|
|
358
|
+
plan.page_number, 0, "none", "partial", "native_text_failed"
|
|
359
|
+
)
|
|
360
|
+
if plan.native_text.strip():
|
|
361
|
+
return PageMeasurement(
|
|
362
|
+
plan.page_number, len(plan.native_text), "native", "complete"
|
|
363
|
+
)
|
|
364
|
+
# Without OCR, an empty text layer cannot be distinguished from a
|
|
365
|
+
# scanned page, so the page is reported as incomplete.
|
|
366
|
+
return PageMeasurement(plan.page_number, 0, "none", "partial", "ocr_skipped")
|
|
367
|
+
|
|
368
|
+
def _recognize_batch(self, plans: list) -> list:
|
|
369
|
+
"""Recognize the rendered pages of one batch, in page order."""
|
|
370
|
+
pending = [plan for plan in plans if plan.measurement is None]
|
|
371
|
+
if pending:
|
|
372
|
+
if len(pending) > 1 and self.ocr_workers > 1:
|
|
373
|
+
# Created before fan-out so workers share one reader object.
|
|
374
|
+
self._resolve_reader()
|
|
375
|
+
results = list(self._pool().map(self._recognized_text, pending))
|
|
376
|
+
else:
|
|
377
|
+
results = [self._recognized_text(plan) for plan in pending]
|
|
378
|
+
for plan, (text, error_code) in zip(pending, results):
|
|
379
|
+
plan.measurement = self._from_recognition(plan, text, error_code)
|
|
380
|
+
return [plan.measurement for plan in plans]
|
|
381
|
+
|
|
382
|
+
def _recognized_text(self, plan: _PagePlan) -> Tuple[str, Optional[str]]:
|
|
383
|
+
"""Recognize one rendered page, reporting bounded failures."""
|
|
384
|
+
reader = self._resolve_reader()
|
|
385
|
+
try:
|
|
386
|
+
text = reader(plan.image)
|
|
387
|
+
except OcrEngineUnavailableError:
|
|
388
|
+
return "", "ocr_unavailable"
|
|
389
|
+
except Exception:
|
|
390
|
+
return "", "ocr_failed"
|
|
391
|
+
finally:
|
|
392
|
+
plan.image = None
|
|
393
|
+
|
|
394
|
+
if not isinstance(text, str):
|
|
395
|
+
return "", "ocr_failed"
|
|
396
|
+
return _normalized(text), None
|
|
397
|
+
|
|
398
|
+
def _from_recognition(
|
|
399
|
+
self, plan: _PagePlan, text: str, error_code: Optional[str]
|
|
400
|
+
) -> PageMeasurement:
|
|
401
|
+
"""Build a page result, falling back to any usable native count."""
|
|
402
|
+
if error_code is None:
|
|
403
|
+
return PageMeasurement(plan.page_number, len(text), "ocr", "complete")
|
|
404
|
+
return self._ocr_unavailable(plan, error_code)
|
|
405
|
+
|
|
406
|
+
def _ocr_unavailable(self, plan: _PagePlan, error_code: str) -> PageMeasurement:
|
|
407
|
+
"""Report a page OCR could not measure, keeping a usable native count."""
|
|
408
|
+
if not plan.native_failed and plan.native_text.strip():
|
|
409
|
+
return PageMeasurement(
|
|
410
|
+
plan.page_number,
|
|
411
|
+
len(plan.native_text),
|
|
412
|
+
"native",
|
|
413
|
+
"partial",
|
|
414
|
+
error_code,
|
|
415
|
+
)
|
|
416
|
+
return PageMeasurement(plan.page_number, 0, "none", "partial", error_code)
|
|
417
|
+
|
|
418
|
+
def _resolve_reader(self) -> Callable[[Any], str]:
|
|
419
|
+
"""Return the configured reader, creating the built-in one on demand."""
|
|
420
|
+
with self._reader_lock:
|
|
421
|
+
if self._ocr_reader is None:
|
|
422
|
+
self._ocr_reader = create_default_ocr_reader(
|
|
423
|
+
workers=self.ocr_workers, **self.ocr_engine_params
|
|
424
|
+
)
|
|
425
|
+
return self._ocr_reader
|
|
426
|
+
|
|
427
|
+
def _pool(self) -> ThreadPoolExecutor:
|
|
428
|
+
"""Return this measurer's OCR worker pool, creating it on demand."""
|
|
429
|
+
with self._reader_lock:
|
|
430
|
+
if self._ocr_pool is None:
|
|
431
|
+
self._ocr_pool = ThreadPoolExecutor(
|
|
432
|
+
max_workers=self.ocr_workers,
|
|
433
|
+
thread_name_prefix="structx-measure-ocr",
|
|
434
|
+
)
|
|
435
|
+
return self._ocr_pool
|
|
436
|
+
|
|
437
|
+
def close(self) -> None:
|
|
438
|
+
"""Release worker threads held for parallel OCR.
|
|
439
|
+
|
|
440
|
+
Measuring again after closing recreates them.
|
|
441
|
+
"""
|
|
442
|
+
with self._reader_lock:
|
|
443
|
+
pool, self._ocr_pool = self._ocr_pool, None
|
|
444
|
+
if pool is not None:
|
|
445
|
+
pool.shutdown(wait=True)
|
|
446
|
+
|
|
447
|
+
def _rendered_image(self, page: Any) -> Any:
|
|
448
|
+
"""Render a page to pixels within the configured pixel budget."""
|
|
449
|
+
bitmap = page.render(scale=self._render_scale(page))
|
|
450
|
+
try:
|
|
451
|
+
return bitmap.to_numpy()
|
|
452
|
+
finally:
|
|
453
|
+
bitmap.close()
|
|
454
|
+
|
|
455
|
+
def _render_scale(self, page: Any) -> float:
|
|
456
|
+
"""Scale a page to the requested DPI without exceeding the pixel limit.
|
|
457
|
+
|
|
458
|
+
The rendered bitmap uses whole pixels, so the scale is reduced until the
|
|
459
|
+
rounded-up dimensions also fit the budget.
|
|
460
|
+
"""
|
|
461
|
+
width, height = page.get_size()
|
|
462
|
+
if width <= 0 or height <= 0:
|
|
463
|
+
raise ValueError("Page has no renderable area")
|
|
464
|
+
|
|
465
|
+
scale = self.render_dpi / _PDF_POINTS_PER_INCH
|
|
466
|
+
for _ in range(_RENDER_SCALE_ATTEMPTS):
|
|
467
|
+
pixels = ceil(width * scale) * ceil(height * scale)
|
|
468
|
+
if pixels <= self.max_render_pixels:
|
|
469
|
+
break
|
|
470
|
+
scale *= sqrt(self.max_render_pixels / pixels) * _RENDER_SCALE_MARGIN
|
|
471
|
+
else:
|
|
472
|
+
raise ValueError("Page cannot be rendered within the pixel limit")
|
|
473
|
+
|
|
474
|
+
if width * scale < 1 or height * scale < 1:
|
|
475
|
+
raise ValueError("Page cannot be rendered within the pixel limit")
|
|
476
|
+
return scale
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _is_encrypted(pdfium: Any, document: Any) -> bool:
|
|
480
|
+
"""Detect encrypted documents, including empty-password encryption."""
|
|
481
|
+
security_revision = getattr(pdfium.raw, "FPDF_GetSecurityHandlerRevision", None)
|
|
482
|
+
if security_revision is None: # pragma: no cover - backend without the symbol
|
|
483
|
+
return False
|
|
484
|
+
try:
|
|
485
|
+
return security_revision(document) >= 0
|
|
486
|
+
except Exception: # pragma: no cover - defensive backend guard
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _native_text(page: Any) -> Tuple[str, bool]:
|
|
491
|
+
"""Read a page's embedded text layer.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
The normalized text and a flag indicating that reading it failed.
|
|
495
|
+
"""
|
|
496
|
+
try:
|
|
497
|
+
text_page = page.get_textpage()
|
|
498
|
+
except Exception:
|
|
499
|
+
return "", True
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
return _normalized(text_page.get_text_bounded()), False
|
|
503
|
+
except Exception:
|
|
504
|
+
return "", True
|
|
505
|
+
finally:
|
|
506
|
+
text_page.close()
|