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.
Files changed (54) hide show
  1. {structx-0.6.3/structx.egg-info → structx-0.6.5}/PKG-INFO +33 -2
  2. {structx-0.6.3 → structx-0.6.5}/README.md +26 -1
  3. {structx-0.6.3 → structx-0.6.5}/pyproject.toml +16 -2
  4. {structx-0.6.3 → structx-0.6.5}/structx/__init__.py +1 -1
  5. structx-0.6.5/structx/measurement.py +506 -0
  6. structx-0.6.5/structx/utils/measurement_ocr.py +138 -0
  7. {structx-0.6.3 → structx-0.6.5/structx.egg-info}/PKG-INFO +33 -2
  8. {structx-0.6.3 → structx-0.6.5}/structx.egg-info/SOURCES.txt +3 -0
  9. {structx-0.6.3 → structx-0.6.5}/structx.egg-info/requires.txt +8 -0
  10. structx-0.6.5/tests/test_measurement.py +602 -0
  11. {structx-0.6.3 → structx-0.6.5}/LICENSE +0 -0
  12. {structx-0.6.3 → structx-0.6.5}/setup.cfg +0 -0
  13. {structx-0.6.3 → structx-0.6.5}/structx/core/__init__.py +0 -0
  14. {structx-0.6.3 → structx-0.6.5}/structx/core/config.py +0 -0
  15. {structx-0.6.3 → structx-0.6.5}/structx/core/exceptions.py +0 -0
  16. {structx-0.6.3 → structx-0.6.5}/structx/core/input.py +0 -0
  17. {structx-0.6.3 → structx-0.6.5}/structx/core/models.py +0 -0
  18. {structx-0.6.3 → structx-0.6.5}/structx/core/type_system.py +0 -0
  19. {structx-0.6.3 → structx-0.6.5}/structx/extraction/__init__.py +0 -0
  20. {structx-0.6.3 → structx-0.6.5}/structx/extraction/core/__init__.py +0 -0
  21. {structx-0.6.3 → structx-0.6.5}/structx/extraction/core/llm_core.py +0 -0
  22. {structx-0.6.3 → structx-0.6.5}/structx/extraction/engines/__init__.py +0 -0
  23. {structx-0.6.3 → structx-0.6.5}/structx/extraction/engines/extraction_engine.py +0 -0
  24. {structx-0.6.3 → structx-0.6.5}/structx/extraction/extractor.py +0 -0
  25. {structx-0.6.3 → structx-0.6.5}/structx/extraction/generator.py +0 -0
  26. {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/__init__.py +0 -0
  27. {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/batch_processor.py +0 -0
  28. {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/content_analyzer.py +0 -0
  29. {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/input_processor.py +0 -0
  30. {structx-0.6.3 → structx-0.6.5}/structx/extraction/processors/model_operations.py +0 -0
  31. {structx-0.6.3 → structx-0.6.5}/structx/extraction/result_manager.py +0 -0
  32. {structx-0.6.3 → structx-0.6.5}/structx/schema.py +0 -0
  33. {structx-0.6.3 → structx-0.6.5}/structx/utils/__init__.py +0 -0
  34. {structx-0.6.3 → structx-0.6.5}/structx/utils/file_reader.py +0 -0
  35. {structx-0.6.3 → structx-0.6.5}/structx/utils/helpers.py +0 -0
  36. {structx-0.6.3 → structx-0.6.5}/structx/utils/prompts.py +0 -0
  37. {structx-0.6.3 → structx-0.6.5}/structx/utils/types.py +0 -0
  38. {structx-0.6.3 → structx-0.6.5}/structx/utils/usage.py +0 -0
  39. {structx-0.6.3 → structx-0.6.5}/structx.egg-info/dependency_links.txt +0 -0
  40. {structx-0.6.3 → structx-0.6.5}/structx.egg-info/top_level.txt +0 -0
  41. {structx-0.6.3 → structx-0.6.5}/tests/test_config_and_model_utils.py +0 -0
  42. {structx-0.6.3 → structx-0.6.5}/tests/test_content_analyzer.py +0 -0
  43. {structx-0.6.3 → structx-0.6.5}/tests/test_document_conversion_integration.py +0 -0
  44. {structx-0.6.3 → structx-0.6.5}/tests/test_extraction_engine.py +0 -0
  45. {structx-0.6.3 → structx-0.6.5}/tests/test_extractor_async.py +0 -0
  46. {structx-0.6.3 → structx-0.6.5}/tests/test_extractor_flow.py +0 -0
  47. {structx-0.6.3 → structx-0.6.5}/tests/test_file_reader.py +0 -0
  48. {structx-0.6.3 → structx-0.6.5}/tests/test_input_and_batch_processors.py +0 -0
  49. {structx-0.6.3 → structx-0.6.5}/tests/test_live_llm.py +0 -0
  50. {structx-0.6.3 → structx-0.6.5}/tests/test_llm_configuration.py +0 -0
  51. {structx-0.6.3 → structx-0.6.5}/tests/test_result_manager_and_helpers.py +0 -0
  52. {structx-0.6.3 → structx-0.6.5}/tests/test_schema_contract.py +0 -0
  53. {structx-0.6.3 → structx-0.6.5}/tests/test_type_system_and_planning.py +0 -0
  54. {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
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
  [![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.5-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.5-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.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.3"
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
@@ -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.5"
26
26
  __all__ = [
27
27
  "Extractor",
28
28
  "ExtractionConfig",
@@ -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()