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.
@@ -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()