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.
- extract_python-0.11.4/.python-version +1 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/PKG-INFO +7 -2
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/__init__.py +6 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/docling_.py +2 -2
- extract_python-0.11.4/extract_python/liteparse_.py +493 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/utils.py +98 -5
- {extract_python-0.11.3 → extract_python-0.11.4}/pyproject.toml +16 -1
- {extract_python-0.11.3 → extract_python-0.11.4}/uv.lock +856 -1311
- extract_python-0.11.3/.python-version +0 -1
- {extract_python-0.11.3 → extract_python-0.11.4}/.gitignore +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/README.md +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/benches/__init__.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/benches/compare.ipynb +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/benches/compare.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/benches/constants.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/data/.gitignore +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/__main__.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/cli/__init__.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/cli/doc.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/cli/utils.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/constants.py +0 -0
- {extract_python-0.11.3 → extract_python-0.11.4}/extract_python/marker_.py +0 -0
- {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
|
+
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.
|
|
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
|
|
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
|
|
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
|
|
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) ->
|
|
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.
|
|
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 !")
|