structx 0.6.3__tar.gz → 0.6.4__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.4}/PKG-INFO +33 -2
- {structx-0.6.3 → structx-0.6.4}/README.md +26 -1
- {structx-0.6.3 → structx-0.6.4}/pyproject.toml +16 -2
- {structx-0.6.3 → structx-0.6.4}/structx/__init__.py +1 -1
- structx-0.6.4/structx/measurement.py +408 -0
- structx-0.6.4/structx/utils/measurement_ocr.py +92 -0
- {structx-0.6.3 → structx-0.6.4/structx.egg-info}/PKG-INFO +33 -2
- {structx-0.6.3 → structx-0.6.4}/structx.egg-info/SOURCES.txt +3 -0
- {structx-0.6.3 → structx-0.6.4}/structx.egg-info/requires.txt +8 -0
- structx-0.6.4/tests/test_measurement.py +464 -0
- {structx-0.6.3 → structx-0.6.4}/LICENSE +0 -0
- {structx-0.6.3 → structx-0.6.4}/setup.cfg +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/core/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/core/config.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/core/exceptions.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/core/input.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/core/models.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/core/type_system.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/core/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/core/llm_core.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/engines/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/engines/extraction_engine.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/extractor.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/generator.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/batch_processor.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/content_analyzer.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/input_processor.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/model_operations.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/extraction/result_manager.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/schema.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/utils/__init__.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/utils/file_reader.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/utils/helpers.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/utils/prompts.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/utils/types.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx/utils/usage.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx.egg-info/dependency_links.txt +0 -0
- {structx-0.6.3 → structx-0.6.4}/structx.egg-info/top_level.txt +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_config_and_model_utils.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_content_analyzer.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_document_conversion_integration.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_extraction_engine.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_extractor_async.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_extractor_flow.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_file_reader.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_input_and_batch_processors.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_live_llm.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_llm_configuration.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_result_manager_and_helpers.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_schema_contract.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/tests/test_type_system_and_planning.py +0 -0
- {structx-0.6.3 → structx-0.6.4}/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.4
|
|
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.4"
|
|
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.4"
|
|
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,408 @@
|
|
|
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 dataclasses import dataclass, field
|
|
25
|
+
from math import ceil, sqrt
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Optional, Tuple, Union
|
|
28
|
+
|
|
29
|
+
from structx.core.exceptions import ConfigurationError, FileError
|
|
30
|
+
from structx.utils.measurement_ocr import (
|
|
31
|
+
OcrEngineUnavailableError,
|
|
32
|
+
create_default_ocr_reader,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
ESTIMATOR_REVISION = "1"
|
|
36
|
+
"""Revision of the counting algorithm.
|
|
37
|
+
|
|
38
|
+
This identifies how counts were produced. It is not a promise that optional OCR
|
|
39
|
+
models or their versions are frozen.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
OCR_MODES: Tuple[str, ...] = ("never", "auto", "always")
|
|
43
|
+
|
|
44
|
+
MEASUREMENT_BYTES_LIMIT = 50 * 1024 * 1024
|
|
45
|
+
MEASUREMENT_PAGE_LIMIT = 500
|
|
46
|
+
MEASUREMENT_RENDER_DPI = 150.0
|
|
47
|
+
MEASUREMENT_RENDER_PIXEL_LIMIT = 12_000_000
|
|
48
|
+
|
|
49
|
+
# PDFium is not thread-safe, so all measurement work through this module is
|
|
50
|
+
# serialized. Run heavy OCR workloads in separate processes instead.
|
|
51
|
+
_PDFIUM_LOCK = threading.Lock()
|
|
52
|
+
|
|
53
|
+
_PDF_POINTS_PER_INCH = 72.0
|
|
54
|
+
_RENDER_SCALE_ATTEMPTS = 8
|
|
55
|
+
_RENDER_SCALE_MARGIN = 0.999
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class PageMeasurement:
|
|
60
|
+
"""Measured content of one page.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
page_number: One-based page position in the source document.
|
|
64
|
+
character_count: Unicode code points observed on the page.
|
|
65
|
+
method: How the count was produced: ``native``, ``ocr``, or ``none``.
|
|
66
|
+
status: ``complete`` when the page was measured, ``partial`` when the
|
|
67
|
+
count is known to be incomplete or unavailable.
|
|
68
|
+
error_code: Stable reason for a partial page, otherwise ``None``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
page_number: int
|
|
72
|
+
character_count: int
|
|
73
|
+
method: str
|
|
74
|
+
status: str
|
|
75
|
+
error_code: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class DocumentMeasurement:
|
|
80
|
+
"""Measured content of one document.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
pages: Page measurements in source order.
|
|
84
|
+
ocr_mode: OCR mode used for this measurement.
|
|
85
|
+
estimator_revision: Revision of the counting algorithm.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
pages: Tuple[PageMeasurement, ...] = field(default_factory=tuple)
|
|
89
|
+
ocr_mode: str = "never"
|
|
90
|
+
estimator_revision: str = ESTIMATOR_REVISION
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def page_count(self) -> int:
|
|
94
|
+
"""Number of pages in the measured document."""
|
|
95
|
+
return len(self.pages)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def character_count(self) -> int:
|
|
99
|
+
"""Total characters observed across measured pages."""
|
|
100
|
+
return sum(page.character_count for page in self.pages)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def status(self) -> str:
|
|
104
|
+
"""``complete`` only when every page of a non-empty document was measured."""
|
|
105
|
+
if not self.pages:
|
|
106
|
+
return "partial"
|
|
107
|
+
if all(page.status == "complete" for page in self.pages):
|
|
108
|
+
return "complete"
|
|
109
|
+
return "partial"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def error_codes(self) -> Tuple[str, ...]:
|
|
113
|
+
"""Distinct partial-page reasons, in first-seen order."""
|
|
114
|
+
codes: list = []
|
|
115
|
+
for page in self.pages:
|
|
116
|
+
if page.error_code and page.error_code not in codes:
|
|
117
|
+
codes.append(page.error_code)
|
|
118
|
+
return tuple(codes)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _positive_int(value: Any, name: str) -> int:
|
|
122
|
+
"""Validate a positive integer setting."""
|
|
123
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
124
|
+
raise ConfigurationError(f"{name} must be a positive integer")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _positive_real(value: Any, name: str) -> float:
|
|
129
|
+
"""Validate a positive finite numeric setting."""
|
|
130
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
131
|
+
raise ConfigurationError(f"{name} must be a positive number")
|
|
132
|
+
number = float(value)
|
|
133
|
+
if number <= 0 or number != number or number in (float("inf"), float("-inf")):
|
|
134
|
+
raise ConfigurationError(f"{name} must be a positive finite number")
|
|
135
|
+
return number
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _normalized(text: str) -> str:
|
|
139
|
+
"""Normalize line endings so counts do not depend on the source platform."""
|
|
140
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _import_pdfium():
|
|
144
|
+
"""Import the optional PDF backend used for measurement."""
|
|
145
|
+
try:
|
|
146
|
+
import pypdfium2
|
|
147
|
+
except Exception as error: # pragma: no cover - import environment
|
|
148
|
+
raise ConfigurationError(
|
|
149
|
+
"Document measurement requires pypdfium2. " "Install structx[measurement]."
|
|
150
|
+
) from error
|
|
151
|
+
return pypdfium2
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DocumentMeasurer:
|
|
155
|
+
"""Estimate page and character counts for PDF documents.
|
|
156
|
+
|
|
157
|
+
The measurer is reusable: one instance can measure many documents and
|
|
158
|
+
reuses a single OCR reader when OCR is enabled. Measurement is synchronous
|
|
159
|
+
and serialized internally.
|
|
160
|
+
|
|
161
|
+
Attributes:
|
|
162
|
+
ocr_mode: ``never`` (default) uses embedded text only. ``auto`` also
|
|
163
|
+
runs OCR for pages with no usable embedded text. ``always`` reads
|
|
164
|
+
every page with OCR instead of its embedded text.
|
|
165
|
+
max_pages: Largest document accepted, in pages.
|
|
166
|
+
max_input_bytes: Largest input accepted, in bytes.
|
|
167
|
+
render_dpi: Resolution used when rendering a page for OCR.
|
|
168
|
+
max_render_pixels: Upper bound on rendered pixels per page. Larger
|
|
169
|
+
pages are rendered at a reduced scale rather than skipped.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
ocr_mode: str = "never",
|
|
176
|
+
ocr_reader: Optional[Callable[[Any], str]] = None,
|
|
177
|
+
max_pages: int = MEASUREMENT_PAGE_LIMIT,
|
|
178
|
+
max_input_bytes: int = MEASUREMENT_BYTES_LIMIT,
|
|
179
|
+
render_dpi: float = MEASUREMENT_RENDER_DPI,
|
|
180
|
+
max_render_pixels: int = MEASUREMENT_RENDER_PIXEL_LIMIT,
|
|
181
|
+
) -> None:
|
|
182
|
+
if ocr_mode not in OCR_MODES:
|
|
183
|
+
raise ConfigurationError(f"ocr_mode must be one of {', '.join(OCR_MODES)}")
|
|
184
|
+
if ocr_reader is not None and not callable(ocr_reader):
|
|
185
|
+
raise ConfigurationError("ocr_reader must be callable")
|
|
186
|
+
|
|
187
|
+
self.ocr_mode = ocr_mode
|
|
188
|
+
self.max_pages = _positive_int(max_pages, "max_pages")
|
|
189
|
+
self.max_input_bytes = _positive_int(max_input_bytes, "max_input_bytes")
|
|
190
|
+
self.render_dpi = _positive_real(render_dpi, "render_dpi")
|
|
191
|
+
self.max_render_pixels = _positive_int(max_render_pixels, "max_render_pixels")
|
|
192
|
+
self._ocr_reader = ocr_reader
|
|
193
|
+
|
|
194
|
+
def measure(self, file_path: Union[str, Path]) -> DocumentMeasurement:
|
|
195
|
+
"""Measure one PDF document.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
file_path: Path to an existing PDF file.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Page and character estimates with per-page completeness.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
FileError: If the file is missing, empty, not a readable PDF, or
|
|
205
|
+
exceeds the configured size or page limits.
|
|
206
|
+
ConfigurationError: If the optional PDF backend is unavailable.
|
|
207
|
+
"""
|
|
208
|
+
path = self._validated_path(file_path)
|
|
209
|
+
pdfium = _import_pdfium()
|
|
210
|
+
with _PDFIUM_LOCK:
|
|
211
|
+
pages = self._measure_pages(pdfium, path)
|
|
212
|
+
return DocumentMeasurement(
|
|
213
|
+
pages=tuple(pages),
|
|
214
|
+
ocr_mode=self.ocr_mode,
|
|
215
|
+
estimator_revision=ESTIMATOR_REVISION,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _validated_path(self, file_path: Union[str, Path]) -> Path:
|
|
219
|
+
"""Reject inputs this API cannot measure before opening them."""
|
|
220
|
+
path = Path(file_path)
|
|
221
|
+
if not path.exists():
|
|
222
|
+
raise FileError(f"File not found: {path}")
|
|
223
|
+
if not path.is_file():
|
|
224
|
+
raise FileError(f"Path is not a file: {path}")
|
|
225
|
+
|
|
226
|
+
size = path.stat().st_size
|
|
227
|
+
if size == 0:
|
|
228
|
+
raise FileError(f"File is empty: {path}")
|
|
229
|
+
if size > self.max_input_bytes:
|
|
230
|
+
raise FileError(
|
|
231
|
+
f"File exceeds the {self.max_input_bytes} byte measurement limit: "
|
|
232
|
+
f"{path}"
|
|
233
|
+
)
|
|
234
|
+
if path.suffix.lower() != ".pdf":
|
|
235
|
+
raise FileError(
|
|
236
|
+
f"Document measurement supports PDF input only: {path.suffix}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
with path.open("rb") as pdf_file:
|
|
240
|
+
header = pdf_file.read(1024)
|
|
241
|
+
if b"%PDF-" not in header:
|
|
242
|
+
raise FileError(f"Invalid PDF file: {path}")
|
|
243
|
+
return path
|
|
244
|
+
|
|
245
|
+
def _measure_pages(self, pdfium: Any, path: Path) -> list:
|
|
246
|
+
"""Open the document once and measure every page in order."""
|
|
247
|
+
try:
|
|
248
|
+
document = pdfium.PdfDocument(path)
|
|
249
|
+
except Exception as error:
|
|
250
|
+
raise FileError(f"Could not open PDF for measurement: {path}") from error
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
if _is_encrypted(pdfium, document):
|
|
254
|
+
raise FileError(f"Encrypted PDFs cannot be measured: {path}")
|
|
255
|
+
|
|
256
|
+
page_count = len(document)
|
|
257
|
+
if page_count < 1:
|
|
258
|
+
raise FileError(f"The PDF contains no pages: {path}")
|
|
259
|
+
if page_count > self.max_pages:
|
|
260
|
+
raise FileError(
|
|
261
|
+
f"PDF exceeds the {self.max_pages} page measurement limit: {path}"
|
|
262
|
+
)
|
|
263
|
+
return [self._measure_page(document, index) for index in range(page_count)]
|
|
264
|
+
finally:
|
|
265
|
+
document.close()
|
|
266
|
+
|
|
267
|
+
def _measure_page(self, document: Any, index: int) -> PageMeasurement:
|
|
268
|
+
"""Measure one page without letting a page failure end the document."""
|
|
269
|
+
page_number = index + 1
|
|
270
|
+
try:
|
|
271
|
+
page = document[index]
|
|
272
|
+
except Exception:
|
|
273
|
+
return PageMeasurement(
|
|
274
|
+
page_number, 0, "none", "partial", "page_load_failed"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
native_text, native_failed = _native_text(page)
|
|
279
|
+
|
|
280
|
+
if self.ocr_mode == "always":
|
|
281
|
+
return self._ocr_page(page, page_number, native_text, native_failed)
|
|
282
|
+
|
|
283
|
+
if native_failed:
|
|
284
|
+
if self.ocr_mode == "auto":
|
|
285
|
+
return self._ocr_page(page, page_number, "", True)
|
|
286
|
+
return PageMeasurement(
|
|
287
|
+
page_number, 0, "none", "partial", "native_text_failed"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if native_text.strip():
|
|
291
|
+
return PageMeasurement(
|
|
292
|
+
page_number, len(native_text), "native", "complete"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if self.ocr_mode == "auto":
|
|
296
|
+
return self._ocr_page(page, page_number, "", False)
|
|
297
|
+
|
|
298
|
+
# Without OCR, an empty text layer cannot be distinguished from a
|
|
299
|
+
# scanned page, so the page is reported as incomplete.
|
|
300
|
+
return PageMeasurement(page_number, 0, "none", "partial", "ocr_skipped")
|
|
301
|
+
finally:
|
|
302
|
+
page.close()
|
|
303
|
+
|
|
304
|
+
def _ocr_page(
|
|
305
|
+
self,
|
|
306
|
+
page: Any,
|
|
307
|
+
page_number: int,
|
|
308
|
+
native_text: str,
|
|
309
|
+
native_failed: bool,
|
|
310
|
+
) -> PageMeasurement:
|
|
311
|
+
"""Measure a page with OCR, falling back to any usable native count."""
|
|
312
|
+
text, error_code = self._recognized_text(page)
|
|
313
|
+
if error_code is None:
|
|
314
|
+
return PageMeasurement(page_number, len(text), "ocr", "complete")
|
|
315
|
+
if not native_failed and native_text.strip():
|
|
316
|
+
return PageMeasurement(
|
|
317
|
+
page_number, len(native_text), "native", "partial", error_code
|
|
318
|
+
)
|
|
319
|
+
return PageMeasurement(page_number, 0, "none", "partial", error_code)
|
|
320
|
+
|
|
321
|
+
def _recognized_text(self, page: Any) -> Tuple[str, Optional[str]]:
|
|
322
|
+
"""Render one page and recognize its text, reporting bounded failures."""
|
|
323
|
+
reader = self._resolve_reader()
|
|
324
|
+
if reader is None:
|
|
325
|
+
return "", "ocr_unavailable"
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
image = self._rendered_image(page)
|
|
329
|
+
except Exception:
|
|
330
|
+
return "", "page_render_failed"
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
text = reader(image)
|
|
334
|
+
except OcrEngineUnavailableError:
|
|
335
|
+
return "", "ocr_unavailable"
|
|
336
|
+
except Exception:
|
|
337
|
+
return "", "ocr_failed"
|
|
338
|
+
|
|
339
|
+
if not isinstance(text, str):
|
|
340
|
+
return "", "ocr_failed"
|
|
341
|
+
return _normalized(text), None
|
|
342
|
+
|
|
343
|
+
def _resolve_reader(self) -> Optional[Callable[[Any], str]]:
|
|
344
|
+
"""Return the configured reader, creating the built-in one on demand."""
|
|
345
|
+
if self._ocr_reader is None:
|
|
346
|
+
self._ocr_reader = create_default_ocr_reader()
|
|
347
|
+
return self._ocr_reader
|
|
348
|
+
|
|
349
|
+
def _rendered_image(self, page: Any) -> Any:
|
|
350
|
+
"""Render a page to pixels within the configured pixel budget."""
|
|
351
|
+
bitmap = page.render(scale=self._render_scale(page))
|
|
352
|
+
try:
|
|
353
|
+
return bitmap.to_numpy()
|
|
354
|
+
finally:
|
|
355
|
+
bitmap.close()
|
|
356
|
+
|
|
357
|
+
def _render_scale(self, page: Any) -> float:
|
|
358
|
+
"""Scale a page to the requested DPI without exceeding the pixel limit.
|
|
359
|
+
|
|
360
|
+
The rendered bitmap uses whole pixels, so the scale is reduced until the
|
|
361
|
+
rounded-up dimensions also fit the budget.
|
|
362
|
+
"""
|
|
363
|
+
width, height = page.get_size()
|
|
364
|
+
if width <= 0 or height <= 0:
|
|
365
|
+
raise ValueError("Page has no renderable area")
|
|
366
|
+
|
|
367
|
+
scale = self.render_dpi / _PDF_POINTS_PER_INCH
|
|
368
|
+
for _ in range(_RENDER_SCALE_ATTEMPTS):
|
|
369
|
+
pixels = ceil(width * scale) * ceil(height * scale)
|
|
370
|
+
if pixels <= self.max_render_pixels:
|
|
371
|
+
break
|
|
372
|
+
scale *= sqrt(self.max_render_pixels / pixels) * _RENDER_SCALE_MARGIN
|
|
373
|
+
else:
|
|
374
|
+
raise ValueError("Page cannot be rendered within the pixel limit")
|
|
375
|
+
|
|
376
|
+
if width * scale < 1 or height * scale < 1:
|
|
377
|
+
raise ValueError("Page cannot be rendered within the pixel limit")
|
|
378
|
+
return scale
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _is_encrypted(pdfium: Any, document: Any) -> bool:
|
|
382
|
+
"""Detect encrypted documents, including empty-password encryption."""
|
|
383
|
+
security_revision = getattr(pdfium.raw, "FPDF_GetSecurityHandlerRevision", None)
|
|
384
|
+
if security_revision is None: # pragma: no cover - backend without the symbol
|
|
385
|
+
return False
|
|
386
|
+
try:
|
|
387
|
+
return security_revision(document) >= 0
|
|
388
|
+
except Exception: # pragma: no cover - defensive backend guard
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _native_text(page: Any) -> Tuple[str, bool]:
|
|
393
|
+
"""Read a page's embedded text layer.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
The normalized text and a flag indicating that reading it failed.
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
text_page = page.get_textpage()
|
|
400
|
+
except Exception:
|
|
401
|
+
return "", True
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
return _normalized(text_page.get_text_bounded()), False
|
|
405
|
+
except Exception:
|
|
406
|
+
return "", True
|
|
407
|
+
finally:
|
|
408
|
+
text_page.close()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Optional OCR adapter for standalone document measurement.
|
|
2
|
+
|
|
3
|
+
This module is only imported by :mod:`structx.measurement`. Extraction never
|
|
4
|
+
loads it, and importing this module does not import an OCR engine: the engine
|
|
5
|
+
is created on first use so that ``rapidocr`` stays an optional dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any, Callable, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from structx.core.exceptions import StructXError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OcrEngineUnavailableError(StructXError):
|
|
15
|
+
"""Raised when an OCR engine is requested but cannot be created."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _joined_text(result: Any) -> str:
|
|
21
|
+
"""Join recognized text lines from a RapidOCR result."""
|
|
22
|
+
lines = getattr(result, "txts", None)
|
|
23
|
+
if not lines:
|
|
24
|
+
return ""
|
|
25
|
+
return "\n".join(str(line) for line in lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RapidOcrReader:
|
|
29
|
+
"""Callable adapter that turns rendered page pixels into recognized text.
|
|
30
|
+
|
|
31
|
+
The engine is created lazily on the first page that needs OCR and then
|
|
32
|
+
reused for every later page measured through the same reader. Calls are
|
|
33
|
+
serialized because a single engine instance is not guaranteed to be
|
|
34
|
+
thread-safe.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
engine_params: Parameters forwarded to ``RapidOCR`` on creation.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, **engine_params: Any) -> None:
|
|
41
|
+
self.engine_params: Dict[str, Any] = dict(engine_params)
|
|
42
|
+
self._engine: Optional[Any] = None
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
|
|
45
|
+
def _ensure_engine(self) -> Any:
|
|
46
|
+
"""Create the RapidOCR engine once, or report that it is unavailable."""
|
|
47
|
+
if self._engine is not None:
|
|
48
|
+
return self._engine
|
|
49
|
+
try:
|
|
50
|
+
from rapidocr import RapidOCR
|
|
51
|
+
except Exception as error: # pragma: no cover - import environment
|
|
52
|
+
raise OcrEngineUnavailableError(
|
|
53
|
+
"RapidOCR is not installed. Install structx[measurement-ocr] "
|
|
54
|
+
"or pass an ocr_reader to DocumentMeasurer."
|
|
55
|
+
) from error
|
|
56
|
+
try:
|
|
57
|
+
self._engine = (
|
|
58
|
+
RapidOCR(**self.engine_params) if self.engine_params else RapidOCR()
|
|
59
|
+
)
|
|
60
|
+
except Exception as error:
|
|
61
|
+
raise OcrEngineUnavailableError(
|
|
62
|
+
f"Could not initialize RapidOCR: {error}"
|
|
63
|
+
) from error
|
|
64
|
+
return self._engine
|
|
65
|
+
|
|
66
|
+
def __call__(self, image: Any) -> str:
|
|
67
|
+
"""Recognize text in one rendered page image.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
image: Rendered page pixels as an RGB array.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Recognized text, or an empty string when nothing was recognized.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
OcrEngineUnavailableError: If the OCR engine cannot be created.
|
|
77
|
+
"""
|
|
78
|
+
engine = self._ensure_engine()
|
|
79
|
+
with self._lock:
|
|
80
|
+
return _joined_text(engine(image))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_default_ocr_reader(**engine_params: Any) -> Callable[[Any], str]:
|
|
84
|
+
"""Create the built-in RapidOCR reader without initializing the engine.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
**engine_params: Optional parameters forwarded to ``RapidOCR``.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A callable that accepts rendered page pixels and returns text.
|
|
91
|
+
"""
|
|
92
|
+
return RapidOcrReader(**engine_params)
|