extract-python 0.11.3__tar.gz → 0.12.0__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.12.0/.python-version +1 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/PKG-INFO +7 -2
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/__init__.py +7 -1
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/docling_.py +2 -2
- extract_python-0.12.0/extract_python/liteparse_.py +493 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/utils.py +98 -5
- {extract_python-0.11.3 → extract_python-0.12.0}/pyproject.toml +16 -1
- {extract_python-0.11.3 → extract_python-0.12.0}/uv.lock +856 -1311
- extract_python-0.11.3/.python-version +0 -1
- {extract_python-0.11.3 → extract_python-0.12.0}/.gitignore +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/README.md +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/benches/__init__.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/benches/compare.ipynb +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/benches/compare.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/benches/constants.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/data/.gitignore +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/__main__.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/cli/__init__.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/cli/doc.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/cli/utils.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/constants.py +0 -0
- {extract_python-0.11.3 → extract_python-0.12.0}/extract_python/marker_.py +0 -0
- /extract_python-0.11.3/extract_python/miner_u.py → /extract_python-0.12.0/extract_python/mineru_.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.
|
|
3
|
+
Version: 0.12.0
|
|
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
|
|
@@ -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
|
|
@@ -16,15 +16,21 @@ except ModuleNotFoundError:
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
try:
|
|
19
|
-
from .
|
|
19
|
+
from .mineru_ import MinerUPipeline
|
|
20
20
|
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]
|