pdf-extractor-ai 0.1.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.
- pdf_extractor_ai-0.1.0/PKG-INFO +218 -0
- pdf_extractor_ai-0.1.0/README.md +206 -0
- pdf_extractor_ai-0.1.0/pyproject.toml +54 -0
- pdf_extractor_ai-0.1.0/src/pdf_extractor_ai/__init__.py +19 -0
- pdf_extractor_ai-0.1.0/src/pdf_extractor_ai/extractor.py +193 -0
- pdf_extractor_ai-0.1.0/src/pdf_extractor_ai/image_summarizer.py +230 -0
- pdf_extractor_ai-0.1.0/src/pdf_extractor_ai/markdown.py +355 -0
- pdf_extractor_ai-0.1.0/src/pdf_extractor_ai/py.typed +0 -0
- pdf_extractor_ai-0.1.0/src/pdf_extractor_ai/reflow.py +504 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pdf-extractor-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Oleh Lokshyn
|
|
6
|
+
Author-email: Oleh Lokshyn <olokshyn@gmail.com>
|
|
7
|
+
Requires-Dist: docling>=2.130.0
|
|
8
|
+
Requires-Dist: pillow>=12.3.0
|
|
9
|
+
Requires-Dist: pypdfium2>=5.13.0
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# pdf-extractor-ai
|
|
14
|
+
|
|
15
|
+
Extract a PDF from bytes into one [Docling](https://github.com/docling-project/docling) document, optionally replace figures with summaries from a vision model you supply, then serialize that document to markdown.
|
|
16
|
+
|
|
17
|
+
The package does not download URLs and does not call a vision API. You pass PDF bytes, and you pass the model when you want summaries.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
PDF bytes → PdfExtractor.extract → DoclingDocument
|
|
21
|
+
↓
|
|
22
|
+
summarize_images (optional)
|
|
23
|
+
↓
|
|
24
|
+
to_markdown
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Reading order is newspaper order: physical page, then left-to-right column, then top to bottom. Headings become the tree. Text, lists, tables, and pictures that follow a heading are its children. A section that continues onto later pages is inserted only after those pages have been read.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
This project uses [uv](https://docs.astral.sh/uv/). From the repository:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv sync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run scripts with `uv run` so they use that environment:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv run python your_script.py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Extract a PDF
|
|
44
|
+
|
|
45
|
+
`extract` returns a `DoclingDocument`. It does not call a vision model and it does not return markdown. `do_ocr` defaults to `False`. Set it to `True` for scanned pages. Tables stay on either way.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from pdf_extractor_ai import PdfExtractor, document_title, to_markdown
|
|
49
|
+
|
|
50
|
+
pdf_bytes = open("report.pdf", "rb").read()
|
|
51
|
+
|
|
52
|
+
with PdfExtractor(num_workers=2) as extractor:
|
|
53
|
+
document = extractor.extract(pdf_bytes, name="report.pdf")
|
|
54
|
+
|
|
55
|
+
print(document_title(document, fallback="report.pdf"))
|
|
56
|
+
print(to_markdown(document))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`num_workers` threads share one queue. Each thread builds its own Docling converter on first use and keeps a private converter for each `do_ocr` value. Use the extractor as a context manager, or call `shutdown()` when you are done.
|
|
60
|
+
|
|
61
|
+
`extract_async` is the same job, awaited from an event loop:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
document = await extractor.extract_async(pdf_bytes, do_ocr=False, max_pages=20)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`max_pages` stops after that many pages. An empty or unreadable PDF raises `ValueError`.
|
|
68
|
+
|
|
69
|
+
## Markdown
|
|
70
|
+
|
|
71
|
+
`to_markdown` walks the document in tree order. Page banners stay open across a page and close when the next page starts, or at the end. A picture that was not summarized becomes `<!-- image -->`. Text that sits inside a picture box is left out. A summary block is written with its line breaks intact.
|
|
72
|
+
|
|
73
|
+
A section heading of level 1 is `##`, because a document title uses `#` and each section level adds one more hash.
|
|
74
|
+
|
|
75
|
+
For a two-page report whose first heading is “Crop prospects” and whose second page starts “Regional outlook”, `to_markdown` returns:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
========== page 1 start ==========
|
|
79
|
+
|
|
80
|
+
## Crop prospects
|
|
81
|
+
|
|
82
|
+
Global cereal production is expected to rise.
|
|
83
|
+
|
|
84
|
+
========== page 1 end ==========
|
|
85
|
+
|
|
86
|
+
========== page 2 start ==========
|
|
87
|
+
|
|
88
|
+
### Regional outlook
|
|
89
|
+
|
|
90
|
+
West Africa faces a rainfall deficit.
|
|
91
|
+
|
|
92
|
+
========== page 2 end ==========
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`document_title` reads that tree. It prefers a page-1 title over a page-1 section heading, then the longest heading at that rank, then the first shallowest heading anywhere, then the fallback you pass. For the document above it returns `Crop prospects`.
|
|
96
|
+
|
|
97
|
+
`split_pages` returns the text inside each banner:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from pdf_extractor_ai import split_pages
|
|
101
|
+
|
|
102
|
+
pages = split_pages(markdown)
|
|
103
|
+
pages[1]
|
|
104
|
+
# '## Crop prospects\n\nGlobal cereal production is expected to rise.'
|
|
105
|
+
pages[2]
|
|
106
|
+
# '### Regional outlook\n\nWest Africa faces a rainfall deficit.'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`is_low_text` is true when the text, after banners and image placeholders are removed, is shorter than `min_chars` (default 200). The sample above is short, so `is_low_text(markdown)` is `True` and `is_low_text(markdown, min_chars=40)` is `False`.
|
|
110
|
+
|
|
111
|
+
`layout_from_markdown` maps those banners and headings onto line spans:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from pdf_extractor_ai.markdown import layout_from_markdown
|
|
115
|
+
|
|
116
|
+
layout = layout_from_markdown(markdown, document)
|
|
117
|
+
layout.document_title
|
|
118
|
+
# 'Crop prospects'
|
|
119
|
+
layout.sections[1].heading_path
|
|
120
|
+
# ('Crop prospects', 'Regional outlook')
|
|
121
|
+
layout.sections[1].page_start, layout.sections[1].page_end
|
|
122
|
+
# (2, 2)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Summarize figures
|
|
126
|
+
|
|
127
|
+
`summarize_images` runs after `extract` and before `to_markdown`. Group pictures by their parent heading and call your model once per section that has at least one picture. Sections with no pictures are skipped. The default concurrency is 3. The input document is left unchanged, and the function returns a new one.
|
|
128
|
+
|
|
129
|
+
Your model matches this protocol. `visual_id` values look like `s0-v0`. Return a map of those ids to summary text.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import asyncio
|
|
133
|
+
|
|
134
|
+
from pdf_extractor_ai import summarize_images, to_markdown
|
|
135
|
+
from pdf_extractor_ai.image_summarizer import SectionVisual, VisualLanguageModel
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class GeminiCharts:
|
|
139
|
+
async def describe_section(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
section_title: str,
|
|
143
|
+
section_text: str,
|
|
144
|
+
visuals: list[SectionVisual],
|
|
145
|
+
) -> dict[str, str]:
|
|
146
|
+
# section_text is the heading plus the other text in that section.
|
|
147
|
+
# visuals[i].png_bytes is a PNG crop. Send those to your vision API.
|
|
148
|
+
return {
|
|
149
|
+
visual.visual_id: "Cereal production rises through 2026."
|
|
150
|
+
for visual in visuals
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def main() -> None:
|
|
155
|
+
summarized = await summarize_images(
|
|
156
|
+
document,
|
|
157
|
+
GeminiCharts(),
|
|
158
|
+
model_name="gemini/gemini-3.7-flash",
|
|
159
|
+
)
|
|
160
|
+
print(to_markdown(summarized))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
asyncio.run(main())
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A successful summary replaces that picture with a text block. A missing id, an empty summary, or a raised error leaves the picture in place, so `to_markdown` still emits `<!-- image -->` for it.
|
|
167
|
+
|
|
168
|
+
Before a summary, a figure under the heading “Charts” looks like this:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
========== page 1 start ==========
|
|
172
|
+
|
|
173
|
+
## Charts
|
|
174
|
+
|
|
175
|
+
Chart context
|
|
176
|
+
|
|
177
|
+
<!-- image -->
|
|
178
|
+
|
|
179
|
+
========== page 1 end ==========
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
After the call above, the same figure is the summary block. `model_name`, page, and bounding box come from the picture, and the last lines are the text your model returned:
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
========== page 1 start ==========
|
|
186
|
+
|
|
187
|
+
## Charts
|
|
188
|
+
|
|
189
|
+
Chart context
|
|
190
|
+
|
|
191
|
+
[Summarized Image: gemini/gemini-3.7-flash]
|
|
192
|
+
page: 1
|
|
193
|
+
bounding box: l=40.0, t=120.0, r=280.0, b=360.0 (TOPLEFT)
|
|
194
|
+
Summary:
|
|
195
|
+
Cereal production rises through 2026.
|
|
196
|
+
|
|
197
|
+
========== page 1 end ==========
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Pictures whose shorter side is under 80 pixels are omitted during extraction.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
uv sync
|
|
206
|
+
uv run ruff format
|
|
207
|
+
uv run ruff check
|
|
208
|
+
uv run mypy
|
|
209
|
+
uv run pytest
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`tests/test_pdf_visual_integration.py` calls Gemini and is skipped unless `GEMINI_API_KEY` is in the environment. Pass the repo env file when you want that test to run:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
uv run --env-file .env pytest
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The rest of the suite does not load Docling weights.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# pdf-extractor-ai
|
|
2
|
+
|
|
3
|
+
Extract a PDF from bytes into one [Docling](https://github.com/docling-project/docling) document, optionally replace figures with summaries from a vision model you supply, then serialize that document to markdown.
|
|
4
|
+
|
|
5
|
+
The package does not download URLs and does not call a vision API. You pass PDF bytes, and you pass the model when you want summaries.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
PDF bytes → PdfExtractor.extract → DoclingDocument
|
|
9
|
+
↓
|
|
10
|
+
summarize_images (optional)
|
|
11
|
+
↓
|
|
12
|
+
to_markdown
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Reading order is newspaper order: physical page, then left-to-right column, then top to bottom. Headings become the tree. Text, lists, tables, and pictures that follow a heading are its children. A section that continues onto later pages is inserted only after those pages have been read.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
This project uses [uv](https://docs.astral.sh/uv/). From the repository:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv sync
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run scripts with `uv run` so they use that environment:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv run python your_script.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Extract a PDF
|
|
32
|
+
|
|
33
|
+
`extract` returns a `DoclingDocument`. It does not call a vision model and it does not return markdown. `do_ocr` defaults to `False`. Set it to `True` for scanned pages. Tables stay on either way.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from pdf_extractor_ai import PdfExtractor, document_title, to_markdown
|
|
37
|
+
|
|
38
|
+
pdf_bytes = open("report.pdf", "rb").read()
|
|
39
|
+
|
|
40
|
+
with PdfExtractor(num_workers=2) as extractor:
|
|
41
|
+
document = extractor.extract(pdf_bytes, name="report.pdf")
|
|
42
|
+
|
|
43
|
+
print(document_title(document, fallback="report.pdf"))
|
|
44
|
+
print(to_markdown(document))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`num_workers` threads share one queue. Each thread builds its own Docling converter on first use and keeps a private converter for each `do_ocr` value. Use the extractor as a context manager, or call `shutdown()` when you are done.
|
|
48
|
+
|
|
49
|
+
`extract_async` is the same job, awaited from an event loop:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
document = await extractor.extract_async(pdf_bytes, do_ocr=False, max_pages=20)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`max_pages` stops after that many pages. An empty or unreadable PDF raises `ValueError`.
|
|
56
|
+
|
|
57
|
+
## Markdown
|
|
58
|
+
|
|
59
|
+
`to_markdown` walks the document in tree order. Page banners stay open across a page and close when the next page starts, or at the end. A picture that was not summarized becomes `<!-- image -->`. Text that sits inside a picture box is left out. A summary block is written with its line breaks intact.
|
|
60
|
+
|
|
61
|
+
A section heading of level 1 is `##`, because a document title uses `#` and each section level adds one more hash.
|
|
62
|
+
|
|
63
|
+
For a two-page report whose first heading is “Crop prospects” and whose second page starts “Regional outlook”, `to_markdown` returns:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
========== page 1 start ==========
|
|
67
|
+
|
|
68
|
+
## Crop prospects
|
|
69
|
+
|
|
70
|
+
Global cereal production is expected to rise.
|
|
71
|
+
|
|
72
|
+
========== page 1 end ==========
|
|
73
|
+
|
|
74
|
+
========== page 2 start ==========
|
|
75
|
+
|
|
76
|
+
### Regional outlook
|
|
77
|
+
|
|
78
|
+
West Africa faces a rainfall deficit.
|
|
79
|
+
|
|
80
|
+
========== page 2 end ==========
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`document_title` reads that tree. It prefers a page-1 title over a page-1 section heading, then the longest heading at that rank, then the first shallowest heading anywhere, then the fallback you pass. For the document above it returns `Crop prospects`.
|
|
84
|
+
|
|
85
|
+
`split_pages` returns the text inside each banner:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from pdf_extractor_ai import split_pages
|
|
89
|
+
|
|
90
|
+
pages = split_pages(markdown)
|
|
91
|
+
pages[1]
|
|
92
|
+
# '## Crop prospects\n\nGlobal cereal production is expected to rise.'
|
|
93
|
+
pages[2]
|
|
94
|
+
# '### Regional outlook\n\nWest Africa faces a rainfall deficit.'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`is_low_text` is true when the text, after banners and image placeholders are removed, is shorter than `min_chars` (default 200). The sample above is short, so `is_low_text(markdown)` is `True` and `is_low_text(markdown, min_chars=40)` is `False`.
|
|
98
|
+
|
|
99
|
+
`layout_from_markdown` maps those banners and headings onto line spans:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from pdf_extractor_ai.markdown import layout_from_markdown
|
|
103
|
+
|
|
104
|
+
layout = layout_from_markdown(markdown, document)
|
|
105
|
+
layout.document_title
|
|
106
|
+
# 'Crop prospects'
|
|
107
|
+
layout.sections[1].heading_path
|
|
108
|
+
# ('Crop prospects', 'Regional outlook')
|
|
109
|
+
layout.sections[1].page_start, layout.sections[1].page_end
|
|
110
|
+
# (2, 2)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Summarize figures
|
|
114
|
+
|
|
115
|
+
`summarize_images` runs after `extract` and before `to_markdown`. Group pictures by their parent heading and call your model once per section that has at least one picture. Sections with no pictures are skipped. The default concurrency is 3. The input document is left unchanged, and the function returns a new one.
|
|
116
|
+
|
|
117
|
+
Your model matches this protocol. `visual_id` values look like `s0-v0`. Return a map of those ids to summary text.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import asyncio
|
|
121
|
+
|
|
122
|
+
from pdf_extractor_ai import summarize_images, to_markdown
|
|
123
|
+
from pdf_extractor_ai.image_summarizer import SectionVisual, VisualLanguageModel
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class GeminiCharts:
|
|
127
|
+
async def describe_section(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
section_title: str,
|
|
131
|
+
section_text: str,
|
|
132
|
+
visuals: list[SectionVisual],
|
|
133
|
+
) -> dict[str, str]:
|
|
134
|
+
# section_text is the heading plus the other text in that section.
|
|
135
|
+
# visuals[i].png_bytes is a PNG crop. Send those to your vision API.
|
|
136
|
+
return {
|
|
137
|
+
visual.visual_id: "Cereal production rises through 2026."
|
|
138
|
+
for visual in visuals
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def main() -> None:
|
|
143
|
+
summarized = await summarize_images(
|
|
144
|
+
document,
|
|
145
|
+
GeminiCharts(),
|
|
146
|
+
model_name="gemini/gemini-3.7-flash",
|
|
147
|
+
)
|
|
148
|
+
print(to_markdown(summarized))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
asyncio.run(main())
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A successful summary replaces that picture with a text block. A missing id, an empty summary, or a raised error leaves the picture in place, so `to_markdown` still emits `<!-- image -->` for it.
|
|
155
|
+
|
|
156
|
+
Before a summary, a figure under the heading “Charts” looks like this:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
========== page 1 start ==========
|
|
160
|
+
|
|
161
|
+
## Charts
|
|
162
|
+
|
|
163
|
+
Chart context
|
|
164
|
+
|
|
165
|
+
<!-- image -->
|
|
166
|
+
|
|
167
|
+
========== page 1 end ==========
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
After the call above, the same figure is the summary block. `model_name`, page, and bounding box come from the picture, and the last lines are the text your model returned:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
========== page 1 start ==========
|
|
174
|
+
|
|
175
|
+
## Charts
|
|
176
|
+
|
|
177
|
+
Chart context
|
|
178
|
+
|
|
179
|
+
[Summarized Image: gemini/gemini-3.7-flash]
|
|
180
|
+
page: 1
|
|
181
|
+
bounding box: l=40.0, t=120.0, r=280.0, b=360.0 (TOPLEFT)
|
|
182
|
+
Summary:
|
|
183
|
+
Cereal production rises through 2026.
|
|
184
|
+
|
|
185
|
+
========== page 1 end ==========
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Pictures whose shorter side is under 80 pixels are omitted during extraction.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
uv sync
|
|
194
|
+
uv run ruff format
|
|
195
|
+
uv run ruff check
|
|
196
|
+
uv run mypy
|
|
197
|
+
uv run pytest
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`tests/test_pdf_visual_integration.py` calls Gemini and is skipped unless `GEMINI_API_KEY` is in the environment. Pass the repo env file when you want that test to run:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
uv run --env-file .env pytest
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The rest of the suite does not load Docling weights.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pdf-extractor-ai"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Oleh Lokshyn", email = "olokshyn@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"docling>=2.130.0",
|
|
12
|
+
"pillow>=12.3.0",
|
|
13
|
+
"pypdfium2>=5.13.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"ipykernel>=7.3.0",
|
|
23
|
+
"mypy>=2.3.1",
|
|
24
|
+
"pytest>=9.1.1",
|
|
25
|
+
"pytest-asyncio>=1.4.0",
|
|
26
|
+
"ruff>=0.16.4",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
extend-exclude = ["notebooks", "src/pdf_extractor_ai/old"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
extend-select = ["I", "ANN"]
|
|
34
|
+
ignore = ["ANN401"] # Allow explicitly chosen Any as an escape hatch
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
pythonpath = ["."]
|
|
39
|
+
python_files = ["*.py"]
|
|
40
|
+
asyncio_mode = "auto"
|
|
41
|
+
markers = [
|
|
42
|
+
"integration: tests that call external services",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
strict = true
|
|
47
|
+
files = ["src/pdf_extractor_ai", "tests"]
|
|
48
|
+
mypy_path = "src"
|
|
49
|
+
explicit_package_bases = true
|
|
50
|
+
exclude = ["^notebooks/", "^src/pdf_extractor_ai/old/"]
|
|
51
|
+
|
|
52
|
+
[[tool.mypy.overrides]]
|
|
53
|
+
module = ["pypdfium2", "pypdfium2.*"]
|
|
54
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Bytes-only PDF extraction that returns a reordered Docling document."""
|
|
2
|
+
|
|
3
|
+
from pdf_extractor_ai.extractor import PdfExtractor
|
|
4
|
+
from pdf_extractor_ai.image_summarizer import summarize_images
|
|
5
|
+
from pdf_extractor_ai.markdown import (
|
|
6
|
+
document_title,
|
|
7
|
+
is_low_text,
|
|
8
|
+
split_pages,
|
|
9
|
+
to_markdown,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"PdfExtractor",
|
|
14
|
+
"document_title",
|
|
15
|
+
"is_low_text",
|
|
16
|
+
"split_pages",
|
|
17
|
+
"summarize_images",
|
|
18
|
+
"to_markdown",
|
|
19
|
+
]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Page-by-page PDF extraction on a pool of private Docling converters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import gc
|
|
7
|
+
import io
|
|
8
|
+
import logging
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from concurrent.futures import Future
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Self
|
|
15
|
+
|
|
16
|
+
from docling_core.types.doc.document import DoclingDocument
|
|
17
|
+
|
|
18
|
+
from pdf_extractor_ai.reflow import SectionAssembler
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_converter(do_ocr: bool) -> Any:
|
|
24
|
+
"""Build a Docling converter. Call this inside the worker that will use it."""
|
|
25
|
+
from docling.datamodel.accelerator_options import (
|
|
26
|
+
AcceleratorDevice,
|
|
27
|
+
AcceleratorOptions,
|
|
28
|
+
)
|
|
29
|
+
from docling.datamodel.base_models import InputFormat
|
|
30
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
31
|
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
|
32
|
+
|
|
33
|
+
pipeline_options = PdfPipelineOptions(
|
|
34
|
+
do_ocr=do_ocr,
|
|
35
|
+
do_table_structure=True,
|
|
36
|
+
generate_picture_images=True,
|
|
37
|
+
images_scale=2.0,
|
|
38
|
+
accelerator_options=AcceleratorOptions(device=AcceleratorDevice.CPU),
|
|
39
|
+
)
|
|
40
|
+
return DocumentConverter(
|
|
41
|
+
format_options={
|
|
42
|
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class _Job:
|
|
49
|
+
pdf_bytes: bytes
|
|
50
|
+
do_ocr: bool
|
|
51
|
+
max_pages: int | None
|
|
52
|
+
name: str
|
|
53
|
+
future: Future[DoclingDocument]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PdfExtractor:
|
|
57
|
+
"""Extract PDFs from a shared queue. Each worker owns its Docling converters."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
num_workers: int = 1,
|
|
62
|
+
*,
|
|
63
|
+
converter_factory: Callable[[bool], Any] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
if num_workers < 1:
|
|
66
|
+
raise ValueError("num_workers must be at least 1")
|
|
67
|
+
self._factory = converter_factory or build_converter
|
|
68
|
+
self._queue: queue.Queue[_Job | None] = queue.Queue()
|
|
69
|
+
self._shutdown_lock = threading.Lock()
|
|
70
|
+
self._stopped = False
|
|
71
|
+
self._threads = [
|
|
72
|
+
threading.Thread(target=self._run, name=f"pdf-extract-{index}", daemon=True)
|
|
73
|
+
for index in range(num_workers)
|
|
74
|
+
]
|
|
75
|
+
for thread in self._threads:
|
|
76
|
+
thread.start()
|
|
77
|
+
|
|
78
|
+
def extract(
|
|
79
|
+
self,
|
|
80
|
+
pdf_bytes: bytes,
|
|
81
|
+
*,
|
|
82
|
+
do_ocr: bool = False,
|
|
83
|
+
max_pages: int | None = None,
|
|
84
|
+
name: str = "document.pdf",
|
|
85
|
+
) -> DoclingDocument:
|
|
86
|
+
return self._submit(
|
|
87
|
+
pdf_bytes,
|
|
88
|
+
do_ocr=do_ocr,
|
|
89
|
+
max_pages=max_pages,
|
|
90
|
+
name=name,
|
|
91
|
+
).result()
|
|
92
|
+
|
|
93
|
+
async def extract_async(
|
|
94
|
+
self,
|
|
95
|
+
pdf_bytes: bytes,
|
|
96
|
+
*,
|
|
97
|
+
do_ocr: bool = False,
|
|
98
|
+
max_pages: int | None = None,
|
|
99
|
+
name: str = "document.pdf",
|
|
100
|
+
) -> DoclingDocument:
|
|
101
|
+
future = self._submit(
|
|
102
|
+
pdf_bytes,
|
|
103
|
+
do_ocr=do_ocr,
|
|
104
|
+
max_pages=max_pages,
|
|
105
|
+
name=name,
|
|
106
|
+
)
|
|
107
|
+
return await asyncio.wrap_future(future)
|
|
108
|
+
|
|
109
|
+
def shutdown(self) -> None:
|
|
110
|
+
with self._shutdown_lock:
|
|
111
|
+
if self._stopped:
|
|
112
|
+
return
|
|
113
|
+
self._stopped = True
|
|
114
|
+
for _thread in self._threads:
|
|
115
|
+
self._queue.put(None)
|
|
116
|
+
for thread in self._threads:
|
|
117
|
+
thread.join(timeout=30)
|
|
118
|
+
|
|
119
|
+
def __enter__(self) -> Self:
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def __exit__(self, *_args: object) -> None:
|
|
123
|
+
self.shutdown()
|
|
124
|
+
|
|
125
|
+
def _submit(
|
|
126
|
+
self,
|
|
127
|
+
pdf_bytes: bytes,
|
|
128
|
+
*,
|
|
129
|
+
do_ocr: bool,
|
|
130
|
+
max_pages: int | None,
|
|
131
|
+
name: str,
|
|
132
|
+
) -> Future[DoclingDocument]:
|
|
133
|
+
if self._stopped:
|
|
134
|
+
raise RuntimeError("PdfExtractor is shut down")
|
|
135
|
+
future: Future[DoclingDocument] = Future()
|
|
136
|
+
self._queue.put(
|
|
137
|
+
_Job(
|
|
138
|
+
pdf_bytes=pdf_bytes,
|
|
139
|
+
do_ocr=do_ocr,
|
|
140
|
+
max_pages=max_pages,
|
|
141
|
+
name=name,
|
|
142
|
+
future=future,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return future
|
|
146
|
+
|
|
147
|
+
def _run(self) -> None:
|
|
148
|
+
converters: dict[bool, Any] = {}
|
|
149
|
+
while True:
|
|
150
|
+
job = self._queue.get()
|
|
151
|
+
if job is None:
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
converter = converters.get(job.do_ocr)
|
|
155
|
+
if converter is None:
|
|
156
|
+
converter = self._factory(job.do_ocr)
|
|
157
|
+
converters[job.do_ocr] = converter
|
|
158
|
+
job.future.set_result(_convert_pdf(converter, job))
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
logger.exception("PDF extract failed for %s", job.name)
|
|
161
|
+
if not job.future.done():
|
|
162
|
+
job.future.set_exception(exc)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _convert_pdf(converter: Any, job: _Job) -> DoclingDocument:
|
|
166
|
+
import pypdfium2 as pdfium
|
|
167
|
+
from docling_core.types.io import DocumentStream
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
pdf = pdfium.PdfDocument(job.pdf_bytes)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
if type(exc).__name__ != "PdfiumError":
|
|
173
|
+
raise
|
|
174
|
+
raise ValueError(f"PDF has no pages: {job.name}") from exc
|
|
175
|
+
try:
|
|
176
|
+
page_count = len(pdf)
|
|
177
|
+
if page_count < 1:
|
|
178
|
+
raise ValueError(f"PDF has no pages: {job.name}")
|
|
179
|
+
limit = page_count if job.max_pages is None else min(page_count, job.max_pages)
|
|
180
|
+
if limit < 1:
|
|
181
|
+
raise ValueError(f"PDF has no pages: {job.name}")
|
|
182
|
+
assembler = SectionAssembler(name=job.name)
|
|
183
|
+
for page_no in range(1, limit + 1):
|
|
184
|
+
source = DocumentStream(name=job.name, stream=io.BytesIO(job.pdf_bytes))
|
|
185
|
+
result = converter.convert(source, page_range=(page_no, page_no))
|
|
186
|
+
try:
|
|
187
|
+
assembler.add_page(result.document, pdf=pdf)
|
|
188
|
+
finally:
|
|
189
|
+
del result
|
|
190
|
+
gc.collect()
|
|
191
|
+
return assembler.finish()
|
|
192
|
+
finally:
|
|
193
|
+
pdf.close()
|