extract-python 0.11.3__tar.gz → 0.11.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 (23) hide show
  1. extract_python-0.11.4/.python-version +1 -0
  2. {extract_python-0.11.3 → extract_python-0.11.4}/PKG-INFO +7 -2
  3. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/__init__.py +6 -0
  4. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/docling_.py +2 -2
  5. extract_python-0.11.4/extract_python/liteparse_.py +493 -0
  6. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/utils.py +98 -5
  7. {extract_python-0.11.3 → extract_python-0.11.4}/pyproject.toml +16 -1
  8. {extract_python-0.11.3 → extract_python-0.11.4}/uv.lock +856 -1311
  9. extract_python-0.11.3/.python-version +0 -1
  10. {extract_python-0.11.3 → extract_python-0.11.4}/.gitignore +0 -0
  11. {extract_python-0.11.3 → extract_python-0.11.4}/README.md +0 -0
  12. {extract_python-0.11.3 → extract_python-0.11.4}/benches/__init__.py +0 -0
  13. {extract_python-0.11.3 → extract_python-0.11.4}/benches/compare.ipynb +0 -0
  14. {extract_python-0.11.3 → extract_python-0.11.4}/benches/compare.py +0 -0
  15. {extract_python-0.11.3 → extract_python-0.11.4}/benches/constants.py +0 -0
  16. {extract_python-0.11.3 → extract_python-0.11.4}/data/.gitignore +0 -0
  17. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/__main__.py +0 -0
  18. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/cli/__init__.py +0 -0
  19. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/cli/doc.py +0 -0
  20. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/cli/utils.py +0 -0
  21. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/constants.py +0 -0
  22. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/marker_.py +0 -0
  23. {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/miner_u.py +0 -0
@@ -0,0 +1 @@
1
+ 3.13
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: extract-python
3
- Version: 0.11.3
3
+ Version: 0.11.4
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.11.3
10
+ Requires-Dist: extract-core~=0.11.4
11
11
  Requires-Dist: icij-common~=0.8.2
12
12
  Requires-Dist: pympler~=1.1
13
13
  Provides-Extra: benches
@@ -21,6 +21,11 @@ Provides-Extra: docling
21
21
  Requires-Dist: docling-slim[feat-ocr-easyocr,feat-ocr-mac,feat-ocr-rapidocr,feat-ocr-tesserocr,standard]~=2.127; extra == 'docling'
22
22
  Provides-Extra: docling-gpu
23
23
  Requires-Dist: docling-slim[feat-ocr-nemotron,models-onnxruntime,models-vlm-inline]~=2.127; extra == 'docling-gpu'
24
+ Provides-Extra: liteparse
25
+ Requires-Dist: liteparse~=2.15; extra == 'liteparse'
26
+ Requires-Dist: pillow~=11.0; extra == 'liteparse'
27
+ Requires-Dist: pypdfium2~=4.0; extra == 'liteparse'
28
+ Requires-Dist: tesserocr~=2.7; extra == 'liteparse'
24
29
  Provides-Extra: marker
25
30
  Requires-Dist: marker-pdf~=1.10; extra == 'marker'
26
31
  Provides-Extra: mineru
@@ -21,10 +21,16 @@ except ModuleNotFoundError:
21
21
  logger.warning("mineru is not available, might be optional...")
22
22
  MinerUPipeline = None
23
23
 
24
+ try:
25
+ from .liteparse_ import LiteParsePipeline
26
+ except ModuleNotFoundError:
27
+ logger.warning("liteparse is not available, might be optional...")
28
+ LiteParsePipeline = None
24
29
 
25
30
  __all__ = [
26
31
  "DoclingPipeline",
27
32
  "DOCLING_DEFAULT_ARTIFACTS_PATH",
28
33
  "MarkerPipeline",
29
34
  "MinerUPipeline",
35
+ "LiteParsePipeline",
30
36
  ]
@@ -101,8 +101,8 @@ class DoclingPipeline(Pipeline):
101
101
  if res is sentinel:
102
102
  break
103
103
  pages = next(processed)
104
- buffer.add(pages, res)
105
- if buffer.is_complete(pages.doc_idx):
104
+ is_complete = buffer.add(pages, res)
105
+ if is_complete:
106
106
  doc_pages = buffer.pop_complete(pages.doc_idx)
107
107
  yield _to_result(
108
108
  doc_pages, pages.doc, output_format, output_path=output_path
@@ -0,0 +1,493 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ from collections import defaultdict
5
+ from collections.abc import AsyncIterable, Callable, Generator, Iterable, Sequence
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass, field
8
+ from functools import cache
9
+ from itertools import batched
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Literal, Self, cast
12
+
13
+ from extract_core import (
14
+ ConversionOutput,
15
+ Error,
16
+ InputDoc,
17
+ OutputFormat,
18
+ Pipeline,
19
+ PipelineType,
20
+ Result,
21
+ Status,
22
+ SupportedExt,
23
+ )
24
+ from pydantic import TypeAdapter
25
+
26
+ from .constants import ARTIFACTS, DEFAULT_MD_PAGE_SEP
27
+ from .utils import (
28
+ ProcessedPages,
29
+ Range,
30
+ ResultBuffer,
31
+ UsageTracker,
32
+ chunk_docs,
33
+ path_to_artifacts_dirname,
34
+ publish_and_consume,
35
+ write_pages,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+ if TYPE_CHECKING:
40
+ from liteparse import LiteParse
41
+ from liteparse.types import (
42
+ ExtractedImage,
43
+ PageComplexityStats,
44
+ PageError,
45
+ ParsedPage,
46
+ ParseResult,
47
+ )
48
+ from PIL import Image
49
+ from tesserocr import PyTessBaseAPI
50
+
51
+ _LITEPARSE_CONVERSION_ERRORS = tuple()
52
+
53
+
54
+ OrientedBatch = tuple[int | None, tuple[ProcessedPages]]
55
+ LiteParseOutputFormat = Literal["text", "json", "markdown"]
56
+
57
+
58
+ @cache
59
+ def get_parse_result_ta() -> TypeAdapter:
60
+ from liteparse.types import ParseResult # noqa: PLC0415
61
+
62
+ return TypeAdapter(ParseResult)
63
+
64
+
65
+ @dataclass
66
+ class LiteParsePageResult:
67
+ pages: list["ParsedPage"] = field(default_factory=list)
68
+ images: list["ExtractedImage"] = field(default_factory=list)
69
+ errors: list["PageError"] = field(default_factory=list)
70
+ _by_ix: (
71
+ dict[int, tuple["ParsedPage", list["ExtractedImage"], list["PageError"]]] | None
72
+ ) = None
73
+
74
+ def filter(self, page: ProcessedPages) -> Self:
75
+ if self._by_ix is None:
76
+ self._by_ix = self._results_by_index()
77
+ pages, images, errors = [], [], []
78
+ for p_i in range(page.page_range[0], page.page_range[1]):
79
+ p, ims, errors = self._by_ix[p_i]
80
+ pages.append(p)
81
+ images.extend(ims)
82
+ errors.extend(errors)
83
+ return LiteParsePageResult(pages=pages, images=images, errors=errors)
84
+
85
+ def _results_by_index(
86
+ self,
87
+ ) -> dict[int, tuple["ParsedPage", list["ExtractedImage"], list["PageError"]]]:
88
+ by_ix = dict()
89
+ for p in self.pages:
90
+ rng_ix = p.page_num - 1
91
+ ims = [i for i in self.images if i.page == p.page_num]
92
+ errors = [e for e in self.errors if e.page_num == p.page_num]
93
+ by_ix[rng_ix] = (p, ims, errors)
94
+ return by_ix
95
+
96
+
97
+ ResultAggregator = Callable[[InputDoc, list[LiteParsePageResult], Path], Result]
98
+
99
+
100
+ @Pipeline.register(PipelineType.LITEPARSE)
101
+ class LiteParsePipeline(Pipeline):
102
+ async def extract_content(
103
+ self, docs: Iterable[InputDoc], output_format: OutputFormat, output_path: Path
104
+ ) -> AsyncIterable[Result]:
105
+ tessdata_path = self._config.tessdata_path
106
+ if tessdata_path is None:
107
+ tessdata_path = os.getenv("TESSDATA_PREFIX")
108
+ if tessdata_path is None:
109
+ msg = "couldn't find tessdata path in config nor TESSDATA_PREFIX env var"
110
+ raise RuntimeError(msg)
111
+ self._tessdata_path = str(tessdata_path)
112
+ if self._config.detect_page_orientation:
113
+ logger.info("reading docs page orientation detection...")
114
+ batches = await self._batch_per_orientation(docs)
115
+ logger.info("done with page orientation detection !")
116
+ else:
117
+ batches = chunk_docs(docs, self._config.page_batch_size)
118
+ batches = ((None, (c,)) for c in batches)
119
+ async for res in self._parse(batches, output_format, output_path):
120
+ yield res
121
+
122
+ async def _batch_per_orientation(
123
+ self, docs: Iterable[InputDoc]
124
+ ) -> list[OrientedBatch]:
125
+ result_queue = asyncio.Queue()
126
+ publisher = self._batch_publisher(docs, result_queue)
127
+ consumer = self._batch_consumer(result_queue)
128
+ batches = [b async for b in publish_and_consume(publisher, consumer=consumer)]
129
+ return batches
130
+
131
+ async def _batch_publisher(
132
+ self, docs: Iterable[InputDoc], queue: asyncio.Queue
133
+ ) -> None:
134
+ sem = asyncio.Semaphore(self._config.n_cpus - 1)
135
+ logger.debug("creating page complexity parser...")
136
+ parser = self._make_complexity_parser()
137
+ logger.debug("creating page complexity parser...")
138
+ parser.warm_up()
139
+ with parser:
140
+ async with asyncio.TaskGroup() as tg:
141
+ for doc in docs:
142
+ if doc.ext == SupportedExt.PDF:
143
+ task = self._detect_complexity_and_queue(
144
+ parser, doc, queue, sem
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
157
+
158
+ batch_size = self._config.page_batch_size
159
+ osd_reader = tesserocr.PyTessBaseAPI(
160
+ self._tessdata_path,
161
+ lang="osd",
162
+ oem=tesserocr.OEM.DEFAULT,
163
+ psm=tesserocr.PSM.OSD_ONLY,
164
+ )
165
+ pages_per_orientation = defaultdict(list)
166
+ doc_ix = -1
167
+ with osd_reader:
168
+ while (res := await queue.get()) is not None:
169
+ doc_ix += 1
170
+ doc, pdf_pages = res
171
+ if pdf_pages is None:
172
+ for batch in chunk_docs([doc], batch_size):
173
+ rng = _liteparse_range(batch.page_range)
174
+ pages_per_orientation[(rng, None)].append(batch)
175
+ continue
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():
197
+ for batch in batched(pages, batch_size, strict=False):
198
+ batch = _pages_from_complexities(doc, doc_ix, *batch) # noqa: PLW2901
199
+ rng = _liteparse_range_from_pages(*batch)
200
+ pages_per_orientation[(rng, orientation)].append(batch)
201
+ # We yield pages per orientation only here to make sure to yield
202
+ # (orientation, range) in the most regular way and avoid reload
203
+ for (_, orientation), batches in pages_per_orientation.items():
204
+ for batch in batches:
205
+ yield orientation, batch
206
+
207
+ async def _parse(
208
+ self,
209
+ batches: Iterable[OrientedBatch],
210
+ output_format: OutputFormat,
211
+ output_path: Path,
212
+ ) -> AsyncIterable[Result]:
213
+ match output_format:
214
+ case OutputFormat.MARKDOWN:
215
+ liteparse_o_fmt, postprocess_fn = "markdown", aggregate_pages_md
216
+ case _:
217
+ fmt = (
218
+ f"unsupported output format for {LiteParsePipeline.__name__}"
219
+ f" {output_format}"
220
+ )
221
+ raise NotImplementedError(fmt)
222
+ result_queue = asyncio.Queue()
223
+ with self._result_buffer() as buffer:
224
+ publisher = self._parse_publisher(batches, liteparse_o_fmt, result_queue)
225
+ consumer = self._parse_consumer(
226
+ result_queue, buffer, postprocess_fn, output_path
227
+ )
228
+ async for res in publish_and_consume(publisher, consumer=consumer):
229
+ yield res
230
+
231
+ async def _parse_publisher(
232
+ self,
233
+ batches: Iterable[OrientedBatch],
234
+ output_format: LiteParseOutputFormat,
235
+ queue: asyncio.Queue,
236
+ ) -> None:
237
+ parser = None
238
+ # Buffer partial conversion to filesystem as large docs are chunked into pieces
239
+ # Prevent more than n_cpu concurrent processing
240
+ sem = asyncio.Semaphore(self._config.n_cpus - 1)
241
+ # Results may return in arbitrary order, we need to wait until parser is not
242
+ # used any longer before
243
+ tracker = UsageTracker()
244
+ current_key = None
245
+ with _close_parser(parser):
246
+ async with asyncio.TaskGroup() as tg:
247
+ for orientation, batch in batches:
248
+ # We avoid recreating the parser at each iteration since it creates
249
+ # a worker pool, instead process in (orientation, page range) order
250
+ # and cache the parser
251
+ page_range = _liteparse_range_from_pages(*batch)
252
+ n_pages = sum(p.n_pages for p in batch)
253
+ key = (page_range, orientation)
254
+ if key != current_key:
255
+ if parser is not None:
256
+ await tracker.wait_until_free()
257
+ parser.__exit__()
258
+ logger.debug("loading parser for %s", key)
259
+ parser = self._make_parser(output_format, key, n_pages=n_pages)
260
+ parser.__enter__().warm_up()
261
+ current_key = key
262
+ await sem.acquire(), await tracker.acquire()
263
+ task = self._convert_and_queue(parser, batch, queue, sem, tracker)
264
+ tg.create_task(task)
265
+ queue.put_nowait(None)
266
+
267
+ async def _parse_consumer(
268
+ self,
269
+ queue: asyncio.Queue,
270
+ buffer: ResultBuffer,
271
+ result_aggregator: ResultAggregator,
272
+ output_path: Path,
273
+ ) -> AsyncIterable[Result]:
274
+ while (queued := await queue.get()) is not None:
275
+ pages, res = queued
276
+ for p in pages:
277
+ filtered = res.filter(p)
278
+ buffer.add(p, filtered)
279
+ doc_ix = pages[0].doc_idx
280
+ doc = pages[0].doc
281
+ if buffer.is_complete(doc_ix):
282
+ res = result_aggregator(doc, buffer.pop_complete(doc_ix), output_path)
283
+ 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
+
297
+ async def _convert_and_queue(
298
+ self,
299
+ parser: "LiteParse",
300
+ pages: tuple[ProcessedPages],
301
+ queue: asyncio.Queue,
302
+ sem: asyncio.Semaphore,
303
+ tracker: UsageTracker,
304
+ ) -> None:
305
+ res = await asyncio.to_thread(parser.parse, pages[0].doc.path)
306
+ sem.release(), await tracker.release()
307
+ res = LiteParsePageResult(
308
+ pages=res.pages, errors=res.page_errors, images=res.images
309
+ )
310
+ queue.put_nowait((pages, res))
311
+
312
+ def _make_parser(
313
+ self,
314
+ output_format: LiteParseOutputFormat,
315
+ key: tuple[str, int | None],
316
+ n_pages: int,
317
+ ) -> "LiteParse":
318
+ from liteparse.parser import LiteParse # noqa: PLC0415
319
+
320
+ page_batch_size = self._config.page_batch_size
321
+ parse_timeout = self._config.per_page_timeout
322
+ if parse_timeout is not None:
323
+ parse_timeout = parse_timeout * page_batch_size
324
+ target_pages, orientation = key
325
+ if orientation is not None:
326
+ orientation = [(p + 1, orientation) for p in range(n_pages)]
327
+ pool_size = self._config.n_cpus - 1
328
+ return LiteParse(
329
+ ocr_enabled=True,
330
+ extract_links=True,
331
+ keep_headers_footers=True,
332
+ extract_structure_tree=True,
333
+ extract_blocks=True,
334
+ ocr_failure_fatal=True,
335
+ # Corrections are per docs we would have to create 1 parser per doc
336
+ page_orientation_corrections=orientation,
337
+ pool_size=pool_size,
338
+ parse_timeout=parse_timeout,
339
+ target_pages=target_pages,
340
+ continue_on_page_error=True,
341
+ output_format=output_format,
342
+ extract_images=True,
343
+ extract_screenshots=False,
344
+ include_complexity=True,
345
+ image_mode="placeholder",
346
+ num_workers=1,
347
+ image_output_dir=None,
348
+ **self._config.config.model_dump(),
349
+ )
350
+
351
+ def _make_complexity_parser(self) -> "LiteParse":
352
+ from liteparse.parser import LiteParse # noqa: PLC0415
353
+
354
+ return LiteParse(ocr_enabled=False, pool_size=self._config.n_cpus - 1)
355
+
356
+ def _result_buffer(self) -> ResultBuffer:
357
+
358
+ buffer = ResultBuffer(
359
+ max_size_bytes=self._config.result_buffer.max_size,
360
+ root=self._config.result_buffer.root,
361
+ save_fn=_save_conversion_result,
362
+ load_fn=_load_conversion_result,
363
+ )
364
+ return buffer
365
+
366
+
367
+ def aggregate_pages_md(
368
+ doc: InputDoc,
369
+ results: Sequence[LiteParsePageResult],
370
+ output_path: Path,
371
+ sep: str = DEFAULT_MD_PAGE_SEP,
372
+ ) -> Result:
373
+ md_dir_name = path_to_artifacts_dirname(doc.path)
374
+ md_dir = output_path / md_dir_name
375
+ md_dir.mkdir(parents=True, exist_ok=True)
376
+ im_dir = md_dir / ARTIFACTS
377
+ im_dir.mkdir(parents=True, exist_ok=True)
378
+ pages, errors = [], []
379
+ results = sorted(results, key=lambda p: p.pages[0].page_num)
380
+ for res in results:
381
+ if res.errors:
382
+ errors.extend(Error.from_liteparse(err) for err in res.errors)
383
+ per_page_md = {p.page_num: md_from_page(p) for p in res.pages}
384
+ for im in res.images:
385
+ if im.bytes is None:
386
+ msg = "images were not extracted, extract_images must be set to True"
387
+ raise ValueError(msg)
388
+ im_path = im_dir / im.name
389
+ im_path.write_bytes(im.bytes)
390
+ page_md = per_page_md[im.page]
391
+ page_md = page_md.replace(im.name, f"{ARTIFACTS}/{im.name}")
392
+ per_page_md[im.page] = page_md
393
+ for _, p in sorted(per_page_md.items()):
394
+ pages.append(p)
395
+ if not errors:
396
+ status = Status.SUCCESS
397
+ else:
398
+ errors_on_all_pages = all(bool(len(p.errors)) for p in pages)
399
+ status = Status.FAILURE if errors_on_all_pages else Status.FAILURE
400
+ md_file = (md_dir / md_dir.name).with_suffix(OutputFormat.MARKDOWN)
401
+ with md_file.open(mode="wb") as f:
402
+ pages = write_pages(pages, sep, f)
403
+ output = ConversionOutput(
404
+ path=md_dir.relative_to(output_path), pages=pages, confidence=None
405
+ )
406
+ return Result(input=doc, status=status, output=output, errors=errors)
407
+
408
+
409
+ def md_from_page(page: "ParsedPage") -> str:
410
+ md = page.markdown
411
+ if "scanned" not in page.complexity.reasons:
412
+ return md
413
+ if md.startswith("![]("):
414
+ md = md.split("\n")[1:]
415
+ if not md[0]:
416
+ md = md[1:]
417
+ md = "\n".join(md)
418
+ return md
419
+
420
+
421
+ def _save_conversion_result(res: "ParseResult", path: Path) -> None:
422
+ path.write_bytes(get_parse_result_ta().dump_json(res))
423
+
424
+
425
+ def _load_conversion_result(path: Path) -> "ParseResult":
426
+ return get_parse_result_ta().validate_json(path.read_text())
427
+
428
+
429
+ @contextmanager
430
+ def _close_parser(parser: "LiteParse | None") -> Generator[None]:
431
+ try:
432
+ yield
433
+ finally:
434
+ if parser is not None:
435
+ parser.close()
436
+
437
+
438
+ def _liteparse_range(rng: Range) -> str:
439
+ # page range is a Python range, liteparse indexes from 1 and upper bound
440
+ # is inclusive
441
+ return f"{rng[0] + 1}-{rng[1]}"
442
+
443
+
444
+ def _liteparse_range_from_pages(*pages: ProcessedPages) -> str:
445
+ pages = sorted(pages, key=lambda p: p.page_range[0])
446
+ ranges = (_liteparse_range(p.page_range) for p in pages)
447
+ return ",".join(ranges)
448
+
449
+
450
+ def _pages_from_complexities(
451
+ doc: InputDoc, doc_ix: int, *complexities: "PageComplexityStats"
452
+ ) -> tuple[ProcessedPages]:
453
+ complexities = sorted(complexities, key=lambda p: p.page_number)
454
+ pages = []
455
+ start, end = None, None
456
+ for p in complexities:
457
+ if start is None:
458
+ start = p.page_number - 1
459
+ end = start + 1
460
+ if p.page_number != end:
461
+ processed = ProcessedPages(doc=doc, doc_idx=doc_ix, page_range=(start, end))
462
+ pages.append(processed)
463
+ start = p.page_number - 1
464
+ end = p.page_number
465
+ processed = ProcessedPages(doc=doc, doc_idx=doc_ix, page_range=(start, end))
466
+ pages.append(processed)
467
+ return pages
468
+
469
+
470
+ _CLIPPED_ORIENTATIONS = [0, 90, 180, 270]
471
+
472
+
473
+ def _should_detect_orientation(page: "PageComplexityStats") -> bool:
474
+ if not page.needs_ocr:
475
+ return False
476
+ reasons = set(page.reasons)
477
+ if "scanned" in reasons:
478
+ return True
479
+ if "garbled" in reasons:
480
+ return True
481
+ if "vector-text" in reasons: # noqa: SIM103
482
+ return True
483
+ return False
484
+
485
+
486
+ def _detect_orientation(osd_reader: "PyTessBaseAPI", page_im: "Image.Image") -> int:
487
+ osd_reader.SetImage(page_im)
488
+ orientation = osd_reader.DetectOrientationScript()["orient_deg"]
489
+ return _clipped_orientation(orientation)
490
+
491
+
492
+ def _clipped_orientation(angle: float) -> int:
493
+ return min((abs(angle - o) % 360, o) for o in _CLIPPED_ORIENTATIONS)[1]
@@ -1,10 +1,18 @@
1
+ import asyncio
1
2
  import itertools
2
3
  import logging
3
4
  import os
4
5
  import shutil
5
6
  import uuid
6
7
  from collections import defaultdict, deque
7
- from collections.abc import Callable, Generator, Iterable, Iterator
8
+ from collections.abc import (
9
+ AsyncIterable,
10
+ Callable,
11
+ Coroutine,
12
+ Generator,
13
+ Iterable,
14
+ Iterator,
15
+ )
8
16
  from contextlib import contextmanager
9
17
  from copy import copy
10
18
  from dataclasses import dataclass
@@ -13,7 +21,8 @@ from itertools import tee
13
21
  from pathlib import Path, PurePath
14
22
  from tempfile import TemporaryDirectory
15
23
  from types import TracebackType
16
- from typing import BinaryIO, Protocol, Self
24
+ from typing import Any, BinaryIO, Protocol, Self
25
+ from warnings import deprecated
17
26
 
18
27
  from extract_core import Error, InputDoc, Pages, Result, Status
19
28
  from pympler import asizeof
@@ -103,11 +112,62 @@ class ProcessedPages:
103
112
  page_range: Range | None = None
104
113
 
105
114
  @property
106
- def page_length(self) -> int:
115
+ def n_pages(self) -> int:
107
116
  if self.page_range is None:
108
117
  return self.doc.n_pages
109
118
  return self.page_range[1] - self.page_range[0]
110
119
 
120
+ @property
121
+ @deprecated("Use n_pages instead")
122
+ def page_length(self) -> int:
123
+ return self.n_pages
124
+
125
+
126
+ def chunk_docs(
127
+ docs: Iterable[InputDoc], n_pages: int, *, offset: int = 0
128
+ ) -> Iterable[ProcessedPages]:
129
+ for doc_i, doc in enumerate(docs):
130
+ doc_idx = offset + doc_i
131
+ for i in range(0, doc.n_pages, n_pages):
132
+ start = i
133
+ end = min(start + n_pages, doc.n_pages)
134
+ rng = (start, end)
135
+ pages = ProcessedPages(doc=doc, doc_idx=doc_idx, page_range=rng)
136
+ yield pages
137
+
138
+
139
+ class UsageTracker:
140
+ def __init__(self):
141
+ self._usages = 0
142
+ self._lock = asyncio.Lock()
143
+ self._is_free = asyncio.Event()
144
+
145
+ async def __aenter__(self) -> Self:
146
+ await self.acquire()
147
+ return self
148
+
149
+ async def __aexit__(
150
+ self,
151
+ exc_type: type[BaseException] | None,
152
+ exc_val: BaseException | None,
153
+ exc_tb: TracebackType | None,
154
+ ):
155
+ await self.release()
156
+
157
+ async def acquire(self) -> None:
158
+ async with self._lock:
159
+ self._is_free.clear()
160
+ self._usages += 1
161
+
162
+ async def release(self) -> None:
163
+ async with self._lock:
164
+ self._usages -= 1
165
+ if self._usages == 0:
166
+ self._is_free.set()
167
+
168
+ async def wait_until_free(self) -> None:
169
+ await self._is_free.wait()
170
+
111
171
 
112
172
  class ResultBuffer[R]:
113
173
  def __init__(
@@ -163,7 +223,7 @@ class ResultBuffer[R]:
163
223
  raise ValueError(msg)
164
224
  return self.__fs_buffer_path
165
225
 
166
- def add(self, processed: ProcessedPages, result: R) -> None:
226
+ def add(self, processed: ProcessedPages, result: R) -> bool:
167
227
  size = asizeof.asizeof(result)
168
228
  if self._current_size + size > self._max_bytes:
169
229
  path = self._page_path(processed.doc_idx)
@@ -174,7 +234,8 @@ class ResultBuffer[R]:
174
234
  self._mem_buffer[processed.doc_idx].append(result)
175
235
  if processed.doc_idx not in self._missing_pages:
176
236
  self._missing_pages[processed.doc_idx] = processed.doc.n_pages
177
- self._missing_pages[processed.doc_idx] -= processed.page_length
237
+ self._missing_pages[processed.doc_idx] -= processed.n_pages
238
+ return self.is_complete(processed.doc_idx)
178
239
 
179
240
  def is_complete(self, doc: int) -> bool:
180
241
  return self._missing_pages[doc] == 0
@@ -294,3 +355,35 @@ def _by_page_ranges(
294
355
  for v in by_range.values():
295
356
  yield tuple(v)
296
357
  return doc_idx
358
+
359
+
360
+ async def publish_and_consume[R](
361
+ publisher: Coroutine[Any, Any, Any], *, consumer: AsyncIterable[R]
362
+ ) -> AsyncIterable[R]:
363
+ # Publish and consume concurrently
364
+ logger.debug("starting publish and consume")
365
+ publish_t = asyncio.create_task(publisher)
366
+ sentinel = object()
367
+ consume_t = asyncio.create_task(anext(consumer, sentinel))
368
+ awaited = [publish_t, consume_t]
369
+ while awaited:
370
+ done, pending = await asyncio.wait(awaited, return_when=asyncio.FIRST_COMPLETED)
371
+ for d in done:
372
+ # Stop everything case of exception
373
+ exc = d.exception()
374
+ if exc:
375
+ for p in pending:
376
+ p.cancel()
377
+ raise exc
378
+ if publish_t in done:
379
+ awaited = [t for t in awaited if t is not publish_t]
380
+ if consume_t in done:
381
+ res = consume_t.result()
382
+ if res is sentinel:
383
+ awaited = [t for t in awaited if t is not consume_t]
384
+ else:
385
+ yield res
386
+ awaited = [t for t in awaited if t is not consume_t]
387
+ consume_t = asyncio.create_task(anext(consumer, sentinel))
388
+ awaited.append(consume_t)
389
+ logger.debug("done with publish and consume !")