extract-python 0.11.4__tar.gz → 0.12.1__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.
- {extract_python-0.11.4 → extract_python-0.12.1}/PKG-INFO +2 -2
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/__init__.py +1 -1
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/liteparse_.py +179 -110
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/utils.py +46 -25
- {extract_python-0.11.4 → extract_python-0.12.1}/pyproject.toml +2 -2
- {extract_python-0.11.4 → extract_python-0.12.1}/.gitignore +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/.python-version +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/README.md +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/benches/__init__.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/benches/compare.ipynb +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/benches/compare.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/benches/constants.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/data/.gitignore +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/__main__.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/cli/__init__.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/cli/doc.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/cli/utils.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/constants.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/docling_.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/extract_python/marker_.py +0 -0
- /extract_python-0.11.4/extract_python/miner_u.py → /extract_python-0.12.1/extract_python/mineru_.py +0 -0
- {extract_python-0.11.4 → extract_python-0.12.1}/uv.lock +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: extract-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.1
|
|
4
4
|
Summary: Structured content extraction
|
|
5
5
|
Project-URL: Homepage, https://github.com/ICIJ/extract-python
|
|
6
6
|
Project-URL: Repository, https://github.com/ICIJ/extract-python
|
|
7
7
|
Project-URL: Issues, https://github.com/ICIJ/extract-python/issues
|
|
8
8
|
Author-email: Clément Doumouro <cdoumouro@icij.org>
|
|
9
9
|
Requires-Python: <3.15,>=3.13
|
|
10
|
-
Requires-Dist: extract-core~=0.
|
|
10
|
+
Requires-Dist: extract-core~=0.12.1
|
|
11
11
|
Requires-Dist: icij-common~=0.8.2
|
|
12
12
|
Requires-Dist: pympler~=1.1
|
|
13
13
|
Provides-Extra: benches
|
|
@@ -3,17 +3,21 @@ import logging
|
|
|
3
3
|
import os
|
|
4
4
|
from collections import defaultdict
|
|
5
5
|
from collections.abc import AsyncIterable, Callable, Generator, Iterable, Sequence
|
|
6
|
+
from concurrent import futures
|
|
7
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
6
8
|
from contextlib import contextmanager
|
|
7
9
|
from dataclasses import dataclass, field
|
|
8
10
|
from functools import cache
|
|
9
11
|
from itertools import batched
|
|
12
|
+
from multiprocessing import get_context
|
|
10
13
|
from pathlib import Path
|
|
11
|
-
from typing import TYPE_CHECKING, Literal, Self
|
|
14
|
+
from typing import TYPE_CHECKING, Literal, Self
|
|
12
15
|
|
|
13
16
|
from extract_core import (
|
|
14
17
|
ConversionOutput,
|
|
15
18
|
Error,
|
|
16
19
|
InputDoc,
|
|
20
|
+
LiteParsePipelineConfig,
|
|
17
21
|
OutputFormat,
|
|
18
22
|
Pipeline,
|
|
19
23
|
PipelineType,
|
|
@@ -22,6 +26,7 @@ from extract_core import (
|
|
|
22
26
|
SupportedExt,
|
|
23
27
|
)
|
|
24
28
|
from pydantic import TypeAdapter
|
|
29
|
+
from tesserocr import tesserocr
|
|
25
30
|
|
|
26
31
|
from .constants import ARTIFACTS, DEFAULT_MD_PAGE_SEP
|
|
27
32
|
from .utils import (
|
|
@@ -29,6 +34,7 @@ from .utils import (
|
|
|
29
34
|
Range,
|
|
30
35
|
ResultBuffer,
|
|
31
36
|
UsageTracker,
|
|
37
|
+
_run_with_concurrency,
|
|
32
38
|
chunk_docs,
|
|
33
39
|
path_to_artifacts_dirname,
|
|
34
40
|
publish_and_consume,
|
|
@@ -37,6 +43,7 @@ from .utils import (
|
|
|
37
43
|
|
|
38
44
|
logger = logging.getLogger(__name__)
|
|
39
45
|
if TYPE_CHECKING:
|
|
46
|
+
import pypdfium2 as pdfium
|
|
40
47
|
from liteparse import LiteParse
|
|
41
48
|
from liteparse.types import (
|
|
42
49
|
ExtractedImage,
|
|
@@ -45,8 +52,6 @@ if TYPE_CHECKING:
|
|
|
45
52
|
ParsedPage,
|
|
46
53
|
ParseResult,
|
|
47
54
|
)
|
|
48
|
-
from PIL import Image
|
|
49
|
-
from tesserocr import PyTessBaseAPI
|
|
50
55
|
|
|
51
56
|
_LITEPARSE_CONVERSION_ERRORS = tuple()
|
|
52
57
|
|
|
@@ -99,6 +104,9 @@ ResultAggregator = Callable[[InputDoc, list[LiteParsePageResult], Path], Result]
|
|
|
99
104
|
|
|
100
105
|
@Pipeline.register(PipelineType.LITEPARSE)
|
|
101
106
|
class LiteParsePipeline(Pipeline):
|
|
107
|
+
def __init__(self, config: LiteParsePipelineConfig):
|
|
108
|
+
super().__init__(config)
|
|
109
|
+
|
|
102
110
|
async def extract_content(
|
|
103
111
|
self, docs: Iterable[InputDoc], output_format: OutputFormat, output_path: Path
|
|
104
112
|
) -> AsyncIterable[Result]:
|
|
@@ -108,99 +116,69 @@ class LiteParsePipeline(Pipeline):
|
|
|
108
116
|
if tessdata_path is None:
|
|
109
117
|
msg = "couldn't find tessdata path in config nor TESSDATA_PREFIX env var"
|
|
110
118
|
raise RuntimeError(msg)
|
|
111
|
-
self._tessdata_path =
|
|
119
|
+
self._tessdata_path = Path(tessdata_path)
|
|
112
120
|
if self._config.detect_page_orientation:
|
|
113
121
|
logger.info("reading docs page orientation detection...")
|
|
114
122
|
batches = await self._batch_per_orientation(docs)
|
|
115
|
-
logger.info("done with page orientation detection !")
|
|
116
123
|
else:
|
|
117
124
|
batches = chunk_docs(docs, self._config.page_batch_size)
|
|
118
125
|
batches = ((None, (c,)) for c in batches)
|
|
126
|
+
logger.info("parsing pages...")
|
|
119
127
|
async for res in self._parse(batches, output_format, output_path):
|
|
120
128
|
yield res
|
|
121
129
|
|
|
122
130
|
async def _batch_per_orientation(
|
|
123
131
|
self, docs: Iterable[InputDoc]
|
|
124
132
|
) -> list[OrientedBatch]:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
batches = [b async for b in publish_and_consume(publisher, consumer=consumer)]
|
|
133
|
+
logger.info("computing page complexities...")
|
|
134
|
+
complexities = await self._compute_complexities(docs)
|
|
135
|
+
batches = [b async for b in self._batch_with_orientation(complexities)]
|
|
129
136
|
return batches
|
|
130
137
|
|
|
131
|
-
async def
|
|
132
|
-
self, docs: Iterable[InputDoc]
|
|
133
|
-
) ->
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
)
|
|
146
|
-
tg.create_task(task)
|
|
147
|
-
else:
|
|
148
|
-
queue.put_nowait((doc, None))
|
|
149
|
-
queue.put_nowait(None)
|
|
150
|
-
|
|
151
|
-
async def _batch_consumer(
|
|
152
|
-
self, queue: asyncio.Queue
|
|
153
|
-
) -> AsyncIterable[OrientedBatch]:
|
|
154
|
-
import pypdfium2 as pdfium # noqa: PLC0415
|
|
155
|
-
import tesserocr # noqa: PLC0415
|
|
156
|
-
from liteparse.types import PageComplexityStats # noqa: PLC0415
|
|
138
|
+
async def _compute_complexities(
|
|
139
|
+
self, docs: Iterable[InputDoc]
|
|
140
|
+
) -> list[tuple[InputDoc, list["PageComplexityStats"]]]:
|
|
141
|
+
docs = list(docs)
|
|
142
|
+
batches = (pages for pages in chunk_docs(docs, self._config.page_batch_size))
|
|
143
|
+
aws = (_compute_batch_complexities(batch) for batch in batches)
|
|
144
|
+
batches = defaultdict(list)
|
|
145
|
+
async for pages, res in _run_with_concurrency(aws, self._config.n_cpus):
|
|
146
|
+
batches[pages.doc_idx].extend(res)
|
|
147
|
+
batches = [
|
|
148
|
+
(docs[doc_idx], sorted(comps, key=lambda c: c.page_number))
|
|
149
|
+
for doc_idx, comps in batches.items()
|
|
150
|
+
]
|
|
151
|
+
return batches
|
|
157
152
|
|
|
153
|
+
async def _batch_with_orientation(
|
|
154
|
+
self, complexities: list[tuple[InputDoc, list["PageComplexityStats"]]]
|
|
155
|
+
) -> AsyncIterable[OrientedBatch]:
|
|
158
156
|
batch_size = self._config.page_batch_size
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
buffer = defaultdict(list)
|
|
177
|
-
try:
|
|
178
|
-
pdf_doc = None
|
|
179
|
-
pages = cast(list[PageComplexityStats], pdf_pages)
|
|
180
|
-
for page in pages:
|
|
181
|
-
# We don't use orientation for regular page, we reserve osd for
|
|
182
|
-
# pages were OCR is required
|
|
183
|
-
if not _should_detect_orientation(page):
|
|
184
|
-
buffer[None].append(page)
|
|
185
|
-
continue
|
|
186
|
-
if pdf_doc is None:
|
|
187
|
-
pdf_doc = pdfium.PdfDocument(doc.path)
|
|
188
|
-
page_im = (
|
|
189
|
-
pdf_doc[page.page_number - 1].render(scale=3.0).to_pil()
|
|
190
|
-
)
|
|
191
|
-
orientation = _detect_orientation(osd_reader, page_im)
|
|
192
|
-
buffer[orientation].append(page)
|
|
193
|
-
finally:
|
|
194
|
-
if pdf_doc is not None:
|
|
195
|
-
pdf_doc.close()
|
|
196
|
-
for orientation, pages in buffer.items():
|
|
157
|
+
batches_per_orientation = defaultdict(list)
|
|
158
|
+
not_pdfs = [doc for doc, comps in complexities if comps is None]
|
|
159
|
+
for doc in not_pdfs:
|
|
160
|
+
for batch in chunk_docs([doc], batch_size):
|
|
161
|
+
rng = _liteparse_range(batch.page_range)
|
|
162
|
+
batches_per_orientation[(rng, None)].append(batch)
|
|
163
|
+
complexities = [
|
|
164
|
+
(doc, comps) for doc, comps in complexities if comps is not None
|
|
165
|
+
]
|
|
166
|
+
logger.info("reading complex pages orientation...")
|
|
167
|
+
offset = len(not_pdfs)
|
|
168
|
+
with self._executor() as executor:
|
|
169
|
+
for doc, comps in complexities:
|
|
170
|
+
pages_by_orientation = await _doc_pages_by_orientation(
|
|
171
|
+
doc, comps, executor, self._tessdata_path
|
|
172
|
+
)
|
|
173
|
+
for orientation, pages in pages_by_orientation.items():
|
|
197
174
|
for batch in batched(pages, batch_size, strict=False):
|
|
198
|
-
batch = _pages_from_complexities(doc,
|
|
175
|
+
batch = _pages_from_complexities(doc, offset, *batch) # noqa: PLW2901
|
|
199
176
|
rng = _liteparse_range_from_pages(*batch)
|
|
200
|
-
|
|
177
|
+
batches_per_orientation[(rng, orientation)].append(batch)
|
|
178
|
+
offset += 1
|
|
201
179
|
# We yield pages per orientation only here to make sure to yield
|
|
202
180
|
# (orientation, range) in the most regular way and avoid reload
|
|
203
|
-
for (_, orientation), batches in
|
|
181
|
+
for (_, orientation), batches in batches_per_orientation.items():
|
|
204
182
|
for batch in batches:
|
|
205
183
|
yield orientation, batch
|
|
206
184
|
|
|
@@ -220,6 +198,7 @@ class LiteParsePipeline(Pipeline):
|
|
|
220
198
|
)
|
|
221
199
|
raise NotImplementedError(fmt)
|
|
222
200
|
result_queue = asyncio.Queue()
|
|
201
|
+
# Buffer partial conversion to filesystem as large docs are chunked into pieces
|
|
223
202
|
with self._result_buffer() as buffer:
|
|
224
203
|
publisher = self._parse_publisher(batches, liteparse_o_fmt, result_queue)
|
|
225
204
|
consumer = self._parse_consumer(
|
|
@@ -235,14 +214,16 @@ class LiteParsePipeline(Pipeline):
|
|
|
235
214
|
queue: asyncio.Queue,
|
|
236
215
|
) -> None:
|
|
237
216
|
parser = None
|
|
238
|
-
|
|
217
|
+
parsers = []
|
|
218
|
+
close_parsers_tasks = []
|
|
239
219
|
# Prevent more than n_cpu concurrent processing
|
|
240
|
-
sem = asyncio.Semaphore(self._config.n_cpus - 1)
|
|
220
|
+
sem = asyncio.Semaphore(max(1, self._config.n_cpus - 1))
|
|
241
221
|
# Results may return in arbitrary order, we need to wait until parser is not
|
|
242
222
|
# used any longer before
|
|
243
223
|
tracker = UsageTracker()
|
|
244
224
|
current_key = None
|
|
245
|
-
|
|
225
|
+
publish_tasks = []
|
|
226
|
+
with _close_parsers(parsers):
|
|
246
227
|
async with asyncio.TaskGroup() as tg:
|
|
247
228
|
for orientation, batch in batches:
|
|
248
229
|
# We avoid recreating the parser at each iteration since it creates
|
|
@@ -253,16 +234,19 @@ class LiteParsePipeline(Pipeline):
|
|
|
253
234
|
key = (page_range, orientation)
|
|
254
235
|
if key != current_key:
|
|
255
236
|
if parser is not None:
|
|
256
|
-
|
|
257
|
-
|
|
237
|
+
close_t = tg.create_task(_await_and_close(tracker, parser))
|
|
238
|
+
close_parsers_tasks.append(close_t)
|
|
258
239
|
logger.debug("loading parser for %s", key)
|
|
259
240
|
parser = self._make_parser(output_format, key, n_pages=n_pages)
|
|
241
|
+
parsers.append(parser)
|
|
260
242
|
parser.__enter__().warm_up()
|
|
261
243
|
current_key = key
|
|
262
|
-
await sem.acquire(), await tracker.acquire()
|
|
244
|
+
await sem.acquire(), await tracker.acquire(parser)
|
|
263
245
|
task = self._convert_and_queue(parser, batch, queue, sem, tracker)
|
|
264
|
-
tg.create_task(task)
|
|
265
|
-
|
|
246
|
+
publish_tasks.append(tg.create_task(task))
|
|
247
|
+
await asyncio.gather(*publish_tasks)
|
|
248
|
+
queue.put_nowait(None)
|
|
249
|
+
await asyncio.gather(*close_parsers_tasks)
|
|
266
250
|
|
|
267
251
|
async def _parse_consumer(
|
|
268
252
|
self,
|
|
@@ -281,18 +265,6 @@ class LiteParsePipeline(Pipeline):
|
|
|
281
265
|
if buffer.is_complete(doc_ix):
|
|
282
266
|
res = result_aggregator(doc, buffer.pop_complete(doc_ix), output_path)
|
|
283
267
|
yield res
|
|
284
|
-
logger.info("done consuming !")
|
|
285
|
-
|
|
286
|
-
async def _detect_complexity_and_queue(
|
|
287
|
-
self,
|
|
288
|
-
parser: "LiteParse",
|
|
289
|
-
doc: InputDoc,
|
|
290
|
-
queue: asyncio.Queue,
|
|
291
|
-
sem: asyncio.Semaphore,
|
|
292
|
-
) -> None:
|
|
293
|
-
pages = await asyncio.to_thread(parser.is_complex, doc.path)
|
|
294
|
-
sem.release()
|
|
295
|
-
queue.put_nowait((doc, pages))
|
|
296
268
|
|
|
297
269
|
async def _convert_and_queue(
|
|
298
270
|
self,
|
|
@@ -302,8 +274,8 @@ class LiteParsePipeline(Pipeline):
|
|
|
302
274
|
sem: asyncio.Semaphore,
|
|
303
275
|
tracker: UsageTracker,
|
|
304
276
|
) -> None:
|
|
305
|
-
res = await
|
|
306
|
-
sem.release(), await tracker.release()
|
|
277
|
+
res = await parser.aparse(pages[0].doc.path)
|
|
278
|
+
sem.release(), await tracker.release(parser)
|
|
307
279
|
res = LiteParsePageResult(
|
|
308
280
|
pages=res.pages, errors=res.page_errors, images=res.images
|
|
309
281
|
)
|
|
@@ -324,7 +296,7 @@ class LiteParsePipeline(Pipeline):
|
|
|
324
296
|
target_pages, orientation = key
|
|
325
297
|
if orientation is not None:
|
|
326
298
|
orientation = [(p + 1, orientation) for p in range(n_pages)]
|
|
327
|
-
pool_size = self._config.n_cpus - 1
|
|
299
|
+
pool_size = max(1, self._config.n_cpus - 1)
|
|
328
300
|
return LiteParse(
|
|
329
301
|
ocr_enabled=True,
|
|
330
302
|
extract_links=True,
|
|
@@ -348,10 +320,13 @@ class LiteParsePipeline(Pipeline):
|
|
|
348
320
|
**self._config.config.model_dump(),
|
|
349
321
|
)
|
|
350
322
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
323
|
+
@contextmanager
|
|
324
|
+
def _executor(self) -> Generator[ProcessPoolExecutor]:
|
|
325
|
+
executor = ProcessPoolExecutor(
|
|
326
|
+
max_workers=self._config.n_cpus, mp_context=get_context("spawn")
|
|
327
|
+
)
|
|
328
|
+
with executor:
|
|
329
|
+
yield executor
|
|
355
330
|
|
|
356
331
|
def _result_buffer(self) -> ResultBuffer:
|
|
357
332
|
|
|
@@ -364,6 +339,18 @@ class LiteParsePipeline(Pipeline):
|
|
|
364
339
|
return buffer
|
|
365
340
|
|
|
366
341
|
|
|
342
|
+
async def _compute_batch_complexities(
|
|
343
|
+
pages: ProcessedPages,
|
|
344
|
+
) -> tuple[ProcessedPages, list["PageComplexityStats"]]:
|
|
345
|
+
from liteparse.parser import LiteParse # noqa: PLC0415
|
|
346
|
+
|
|
347
|
+
target_pages = _liteparse_range_from_pages(pages)
|
|
348
|
+
parser = LiteParse(ocr_enabled=False, target_pages=target_pages)
|
|
349
|
+
with parser:
|
|
350
|
+
comps = await asyncio.to_thread(parser.is_complex, pages.doc.path)
|
|
351
|
+
return pages, comps
|
|
352
|
+
|
|
353
|
+
|
|
367
354
|
def aggregate_pages_md(
|
|
368
355
|
doc: InputDoc,
|
|
369
356
|
results: Sequence[LiteParsePageResult],
|
|
@@ -427,12 +414,12 @@ def _load_conversion_result(path: Path) -> "ParseResult":
|
|
|
427
414
|
|
|
428
415
|
|
|
429
416
|
@contextmanager
|
|
430
|
-
def
|
|
417
|
+
def _close_parsers(parsers: Iterable["LiteParse"]) -> Generator[None]:
|
|
431
418
|
try:
|
|
432
419
|
yield
|
|
433
420
|
finally:
|
|
434
|
-
|
|
435
|
-
|
|
421
|
+
for p in parsers:
|
|
422
|
+
p.close()
|
|
436
423
|
|
|
437
424
|
|
|
438
425
|
def _liteparse_range(rng: Range) -> str:
|
|
@@ -483,11 +470,93 @@ def _should_detect_orientation(page: "PageComplexityStats") -> bool:
|
|
|
483
470
|
return False
|
|
484
471
|
|
|
485
472
|
|
|
486
|
-
def
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
473
|
+
async def _doc_pages_by_orientation(
|
|
474
|
+
doc: InputDoc,
|
|
475
|
+
complexities: list["PageComplexityStats"],
|
|
476
|
+
executor: ProcessPoolExecutor,
|
|
477
|
+
tessdata_path: Path,
|
|
478
|
+
) -> dict[int, list["PageComplexityStats"]]:
|
|
479
|
+
pages_by_orientation = defaultdict(list)
|
|
480
|
+
complexities = [(p, _should_detect_orientation(p)) for p in complexities]
|
|
481
|
+
for p, should_detect in complexities:
|
|
482
|
+
if not should_detect:
|
|
483
|
+
pages_by_orientation[None].append(p)
|
|
484
|
+
complexities = [p for p, should_detect in complexities if should_detect]
|
|
485
|
+
if not complexities:
|
|
486
|
+
return pages_by_orientation
|
|
487
|
+
futs = []
|
|
488
|
+
for c in complexities:
|
|
489
|
+
fut = executor.submit(_detect_page_orientation, doc.path, c, tessdata_path)
|
|
490
|
+
futs.append(fut)
|
|
491
|
+
for fut in futures.as_completed(futs):
|
|
492
|
+
orientation, p = fut.result()
|
|
493
|
+
pages_by_orientation[orientation].append(p)
|
|
494
|
+
return pages_by_orientation
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def _detect_complexity(
|
|
498
|
+
parser: "LiteParse", doc: InputDoc
|
|
499
|
+
) -> tuple[InputDoc, list["PageComplexityStats"] | None]:
|
|
500
|
+
if doc.ext != SupportedExt.PDF:
|
|
501
|
+
return doc, None
|
|
502
|
+
complexities = await asyncio.to_thread(parser.is_complex, doc.path)
|
|
503
|
+
return doc, complexities
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _detect_page_orientation(
|
|
507
|
+
doc: Path, complexity: "PageComplexityStats", tessdata_path: Path
|
|
508
|
+
) -> tuple[int, "PageComplexityStats"]:
|
|
509
|
+
pdf_doc = _PDF_DOC_CACHE.get(doc)
|
|
510
|
+
page = pdf_doc.get_page(complexity.page_number - 1)
|
|
511
|
+
page = page.render(scale=3)
|
|
512
|
+
osd_reader = _get_osd_reader(tessdata_path)
|
|
513
|
+
osd_reader.SetImage(page.to_pil())
|
|
514
|
+
orientation = osd_reader.DetectOrientationScript()
|
|
515
|
+
return _clipped_orientation(orientation["orient_deg"]), complexity
|
|
490
516
|
|
|
491
517
|
|
|
492
518
|
def _clipped_orientation(angle: float) -> int:
|
|
493
519
|
return min((abs(angle - o) % 360, o) for o in _CLIPPED_ORIENTATIONS)[1]
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
async def _await_and_close(tracker: UsageTracker, parser: "LiteParse") -> None:
|
|
523
|
+
await tracker.wait_until_free(parser)
|
|
524
|
+
parser.close()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _get_osd_reader(tessdata_path: Path) -> "tesserocr.PyTessBaseAPI":
|
|
528
|
+
import tesserocr # noqa: PLC0415
|
|
529
|
+
|
|
530
|
+
global _TESSERACT
|
|
531
|
+
if _TESSERACT is None:
|
|
532
|
+
osd_reader = tesserocr.PyTessBaseAPI(
|
|
533
|
+
str(tessdata_path),
|
|
534
|
+
lang="osd",
|
|
535
|
+
oem=tesserocr.OEM.DEFAULT,
|
|
536
|
+
psm=tesserocr.PSM.OSD_ONLY,
|
|
537
|
+
)
|
|
538
|
+
_TESSERACT = osd_reader
|
|
539
|
+
return _TESSERACT
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class _PDFDocCache:
|
|
543
|
+
def __init__(self):
|
|
544
|
+
self._path: Path | None = None
|
|
545
|
+
self._doc: pdfium.PdfDocument | None = None
|
|
546
|
+
|
|
547
|
+
def get(self, path: Path) -> "pdfium.PdfDocument":
|
|
548
|
+
import pypdfium2 as pdfium # noqa: PLC0415
|
|
549
|
+
|
|
550
|
+
if path != self._path:
|
|
551
|
+
if self._doc is not None:
|
|
552
|
+
self._doc.close()
|
|
553
|
+
self._doc = pdfium.PdfDocument(path)
|
|
554
|
+
self._path = path
|
|
555
|
+
return self._doc
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# we have to use a global cache because pdfnium is not thread safe and not
|
|
559
|
+
# pickleable
|
|
560
|
+
_PDF_DOC_CACHE = _PDFDocCache()
|
|
561
|
+
# tesseract is also not pickleable
|
|
562
|
+
_TESSERACT: "tesserocr.PyTessBaseAPI | None" = None
|
|
@@ -6,14 +6,16 @@ import shutil
|
|
|
6
6
|
import uuid
|
|
7
7
|
from collections import defaultdict, deque
|
|
8
8
|
from collections.abc import (
|
|
9
|
+
AsyncGenerator,
|
|
9
10
|
AsyncIterable,
|
|
11
|
+
Awaitable,
|
|
10
12
|
Callable,
|
|
11
13
|
Coroutine,
|
|
12
14
|
Generator,
|
|
13
15
|
Iterable,
|
|
14
16
|
Iterator,
|
|
15
17
|
)
|
|
16
|
-
from contextlib import contextmanager
|
|
18
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
17
19
|
from copy import copy
|
|
18
20
|
from dataclasses import dataclass
|
|
19
21
|
from functools import wraps
|
|
@@ -138,35 +140,38 @@ def chunk_docs(
|
|
|
138
140
|
|
|
139
141
|
class UsageTracker:
|
|
140
142
|
def __init__(self):
|
|
141
|
-
self._usages =
|
|
143
|
+
self._usages = defaultdict(int)
|
|
142
144
|
self._lock = asyncio.Lock()
|
|
143
|
-
self._is_free =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
await self.release()
|
|
156
|
-
|
|
157
|
-
async def acquire(self) -> None:
|
|
145
|
+
self._is_free = dict()
|
|
146
|
+
|
|
147
|
+
@asynccontextmanager
|
|
148
|
+
async def track(self, obj: Any) -> AsyncGenerator[None]:
|
|
149
|
+
await self.acquire(obj)
|
|
150
|
+
try:
|
|
151
|
+
yield
|
|
152
|
+
finally:
|
|
153
|
+
await self.release(obj)
|
|
154
|
+
|
|
155
|
+
async def acquire(self, obj: Any) -> None:
|
|
156
|
+
key = _obj_key(obj)
|
|
158
157
|
async with self._lock:
|
|
159
|
-
self._is_free
|
|
160
|
-
|
|
158
|
+
if key not in self._is_free:
|
|
159
|
+
self._is_free[key] = asyncio.Event()
|
|
160
|
+
self._usages[key] += 1
|
|
161
161
|
|
|
162
|
-
async def release(self) -> None:
|
|
162
|
+
async def release(self, obj: Any) -> None:
|
|
163
|
+
key = _obj_key(obj)
|
|
163
164
|
async with self._lock:
|
|
164
|
-
self._usages -= 1
|
|
165
|
-
if self._usages == 0:
|
|
166
|
-
self._is_free.set()
|
|
165
|
+
self._usages[key] -= 1
|
|
166
|
+
if self._usages[key] == 0:
|
|
167
|
+
self._is_free[key].set()
|
|
168
|
+
|
|
169
|
+
async def wait_until_free(self, obj: Any) -> None:
|
|
170
|
+
await self._is_free[_obj_key(obj)].wait()
|
|
167
171
|
|
|
168
|
-
|
|
169
|
-
|
|
172
|
+
|
|
173
|
+
def _obj_key(obj: Any) -> int:
|
|
174
|
+
return id(obj)
|
|
170
175
|
|
|
171
176
|
|
|
172
177
|
class ResultBuffer[R]:
|
|
@@ -387,3 +392,19 @@ async def publish_and_consume[R](
|
|
|
387
392
|
consume_t = asyncio.create_task(anext(consumer, sentinel))
|
|
388
393
|
awaited.append(consume_t)
|
|
389
394
|
logger.debug("done with publish and consume !")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
async def _run_with_concurrency(
|
|
398
|
+
aws: Iterable[Coroutine | asyncio.Future], max_concurrency: int
|
|
399
|
+
) -> AsyncGenerator:
|
|
400
|
+
max_concurrency = asyncio.Semaphore(max_concurrency)
|
|
401
|
+
aws = (_run_with_semaphore(aw, max_concurrency) for aw in aws)
|
|
402
|
+
async for res in asyncio.as_completed(aws):
|
|
403
|
+
yield await res
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
async def _run_with_semaphore[T](
|
|
407
|
+
aws: Awaitable[T] | asyncio.Future, sem: asyncio.Semaphore
|
|
408
|
+
) -> T:
|
|
409
|
+
async with sem:
|
|
410
|
+
return await aws
|
|
@@ -8,7 +8,7 @@ authors = [
|
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.13,<3.15"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"extract-core~=0.
|
|
11
|
+
"extract-core~=0.12.1",
|
|
12
12
|
"icij-common~=0.8.2",
|
|
13
13
|
"pympler~=1.1",
|
|
14
14
|
]
|
|
@@ -118,7 +118,7 @@ log_cli_level = "INFO"
|
|
|
118
118
|
log_cli_format = "[%(levelname)s][%(asctime)s.%(msecs)03d][%(name)s]: %(message)s"
|
|
119
119
|
markers = [
|
|
120
120
|
"integration: integration test",
|
|
121
|
-
"
|
|
121
|
+
"mineru: minerU test which require conflicting extras",
|
|
122
122
|
]
|
|
123
123
|
|
|
124
124
|
[build-system]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/extract_python-0.11.4/extract_python/miner_u.py → /extract_python-0.12.1/extract_python/mineru_.py
RENAMED
|
File without changes
|
|
File without changes
|