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.
Files changed (54) hide show
  1. {structx-0.6.3/structx.egg-info → structx-0.6.4}/PKG-INFO +33 -2
  2. {structx-0.6.3 → structx-0.6.4}/README.md +26 -1
  3. {structx-0.6.3 → structx-0.6.4}/pyproject.toml +16 -2
  4. {structx-0.6.3 → structx-0.6.4}/structx/__init__.py +1 -1
  5. structx-0.6.4/structx/measurement.py +408 -0
  6. structx-0.6.4/structx/utils/measurement_ocr.py +92 -0
  7. {structx-0.6.3 → structx-0.6.4/structx.egg-info}/PKG-INFO +33 -2
  8. {structx-0.6.3 → structx-0.6.4}/structx.egg-info/SOURCES.txt +3 -0
  9. {structx-0.6.3 → structx-0.6.4}/structx.egg-info/requires.txt +8 -0
  10. structx-0.6.4/tests/test_measurement.py +464 -0
  11. {structx-0.6.3 → structx-0.6.4}/LICENSE +0 -0
  12. {structx-0.6.3 → structx-0.6.4}/setup.cfg +0 -0
  13. {structx-0.6.3 → structx-0.6.4}/structx/core/__init__.py +0 -0
  14. {structx-0.6.3 → structx-0.6.4}/structx/core/config.py +0 -0
  15. {structx-0.6.3 → structx-0.6.4}/structx/core/exceptions.py +0 -0
  16. {structx-0.6.3 → structx-0.6.4}/structx/core/input.py +0 -0
  17. {structx-0.6.3 → structx-0.6.4}/structx/core/models.py +0 -0
  18. {structx-0.6.3 → structx-0.6.4}/structx/core/type_system.py +0 -0
  19. {structx-0.6.3 → structx-0.6.4}/structx/extraction/__init__.py +0 -0
  20. {structx-0.6.3 → structx-0.6.4}/structx/extraction/core/__init__.py +0 -0
  21. {structx-0.6.3 → structx-0.6.4}/structx/extraction/core/llm_core.py +0 -0
  22. {structx-0.6.3 → structx-0.6.4}/structx/extraction/engines/__init__.py +0 -0
  23. {structx-0.6.3 → structx-0.6.4}/structx/extraction/engines/extraction_engine.py +0 -0
  24. {structx-0.6.3 → structx-0.6.4}/structx/extraction/extractor.py +0 -0
  25. {structx-0.6.3 → structx-0.6.4}/structx/extraction/generator.py +0 -0
  26. {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/__init__.py +0 -0
  27. {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/batch_processor.py +0 -0
  28. {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/content_analyzer.py +0 -0
  29. {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/input_processor.py +0 -0
  30. {structx-0.6.3 → structx-0.6.4}/structx/extraction/processors/model_operations.py +0 -0
  31. {structx-0.6.3 → structx-0.6.4}/structx/extraction/result_manager.py +0 -0
  32. {structx-0.6.3 → structx-0.6.4}/structx/schema.py +0 -0
  33. {structx-0.6.3 → structx-0.6.4}/structx/utils/__init__.py +0 -0
  34. {structx-0.6.3 → structx-0.6.4}/structx/utils/file_reader.py +0 -0
  35. {structx-0.6.3 → structx-0.6.4}/structx/utils/helpers.py +0 -0
  36. {structx-0.6.3 → structx-0.6.4}/structx/utils/prompts.py +0 -0
  37. {structx-0.6.3 → structx-0.6.4}/structx/utils/types.py +0 -0
  38. {structx-0.6.3 → structx-0.6.4}/structx/utils/usage.py +0 -0
  39. {structx-0.6.3 → structx-0.6.4}/structx.egg-info/dependency_links.txt +0 -0
  40. {structx-0.6.3 → structx-0.6.4}/structx.egg-info/top_level.txt +0 -0
  41. {structx-0.6.3 → structx-0.6.4}/tests/test_config_and_model_utils.py +0 -0
  42. {structx-0.6.3 → structx-0.6.4}/tests/test_content_analyzer.py +0 -0
  43. {structx-0.6.3 → structx-0.6.4}/tests/test_document_conversion_integration.py +0 -0
  44. {structx-0.6.3 → structx-0.6.4}/tests/test_extraction_engine.py +0 -0
  45. {structx-0.6.3 → structx-0.6.4}/tests/test_extractor_async.py +0 -0
  46. {structx-0.6.3 → structx-0.6.4}/tests/test_extractor_flow.py +0 -0
  47. {structx-0.6.3 → structx-0.6.4}/tests/test_file_reader.py +0 -0
  48. {structx-0.6.3 → structx-0.6.4}/tests/test_input_and_batch_processors.py +0 -0
  49. {structx-0.6.3 → structx-0.6.4}/tests/test_live_llm.py +0 -0
  50. {structx-0.6.3 → structx-0.6.4}/tests/test_llm_configuration.py +0 -0
  51. {structx-0.6.3 → structx-0.6.4}/tests/test_result_manager_and_helpers.py +0 -0
  52. {structx-0.6.3 → structx-0.6.4}/tests/test_schema_contract.py +0 -0
  53. {structx-0.6.3 → structx-0.6.4}/tests/test_type_system_and_planning.py +0 -0
  54. {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
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
  [![Documentation](https://img.shields.io/badge/docs-mkdocs-blue.svg?style=for-the-badge)](https://structx.aolabs.dev "Documentation")
67
- [![PyPI](https://img.shields.io/badge/PyPi-0.6.3-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
73
+ [![PyPI](https://img.shields.io/badge/PyPi-0.6.4-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
68
74
  [![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)](# "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
  [![Documentation](https://img.shields.io/badge/docs-mkdocs-blue.svg?style=for-the-badge)](https://structx.aolabs.dev "Documentation")
7
- [![PyPI](https://img.shields.io/badge/PyPi-0.6.3-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
7
+ [![PyPI](https://img.shields.io/badge/PyPi-0.6.4-blue?style=for-the-badge)](https://pypi.org/project/structx "Package")
8
8
  [![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)](# "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.3"
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.3"
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
@@ -22,7 +22,7 @@ from structx.schema import (
22
22
  model_to_extraction_request,
23
23
  )
24
24
 
25
- __version__ = "0.6.3"
25
+ __version__ = "0.6.4"
26
26
  __all__ = [
27
27
  "Extractor",
28
28
  "ExtractionConfig",
@@ -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)