pdfluent-extract 0.1.0__py3-none-any.whl
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.
- pdfluent_extract/__init__.py +222 -0
- pdfluent_extract/cli.py +167 -0
- pdfluent_extract-0.1.0.dist-info/METADATA +142 -0
- pdfluent_extract-0.1.0.dist-info/RECORD +8 -0
- pdfluent_extract-0.1.0.dist-info/WHEEL +4 -0
- pdfluent_extract-0.1.0.dist-info/entry_points.txt +2 -0
- pdfluent_extract-0.1.0.dist-info/licenses/LICENSE +661 -0
- pdfluent_extract-0.1.0.dist-info/licenses/LICENSE-COMMERCIAL +220 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Copyright (c) 2026 Innovation Trigger B.V.
|
|
2
|
+
#
|
|
3
|
+
# PDFluent is available under two licences, at your option: the GNU AGPLv3, or
|
|
4
|
+
# the PDFluent Commercial Licence. See the LICENSE file in this repository --
|
|
5
|
+
# that file travels with the copy you received, which a URL does not.
|
|
6
|
+
"""PDF and Office files to Markdown, JSON and RAG chunks, in three calls.
|
|
7
|
+
|
|
8
|
+
This package is pure Python: the engine underneath is `pdfluent`, which pip
|
|
9
|
+
pulls in automatically (1.1.0 or later, on every platform the matrix covers).
|
|
10
|
+
There is nothing to compile and no system dependency to install.
|
|
11
|
+
|
|
12
|
+
>>> from pdfluent_extract import to_markdown
|
|
13
|
+
>>> print(to_markdown("report.pdf"))
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any, BinaryIO, Dict, List, Optional, Sequence, Tuple, Union
|
|
21
|
+
|
|
22
|
+
import pdfluent
|
|
23
|
+
from pdfluent.chunks import Chunk, iter_chunks
|
|
24
|
+
|
|
25
|
+
__all__ = ["Chunk", "to_chunks", "to_json", "to_markdown"]
|
|
26
|
+
|
|
27
|
+
#: The only quality level v1 accepts. Anything else is refused by name —
|
|
28
|
+
#: there is no hidden fallback to a slower or a weaker pipeline.
|
|
29
|
+
QUALITIES = ("fast",)
|
|
30
|
+
|
|
31
|
+
#: Tokens to characters for the chunk budget: ~4 characters per token for
|
|
32
|
+
#: English prose. A heuristic, stated here rather than smuggled into a default.
|
|
33
|
+
CHARS_PER_TOKEN = 4
|
|
34
|
+
|
|
35
|
+
Source = Union[str, bytes, "os.PathLike[str]", BinaryIO]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_quality(quality: str) -> None:
|
|
39
|
+
if quality not in QUALITIES:
|
|
40
|
+
allowed = ", ".join(repr(q) for q in QUALITIES)
|
|
41
|
+
raise ValueError(f"quality must be one of {allowed}, not {quality!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_source(source: Source) -> Tuple[bytes, Optional[str], Optional[str]]:
|
|
45
|
+
"""The source as ``(data, path, name)``.
|
|
46
|
+
|
|
47
|
+
``path`` is set for path inputs (so chunks can cite it); ``name`` is the
|
|
48
|
+
file stem for paths, ``None`` otherwise. File-like objects are read fully;
|
|
49
|
+
the stream position is left wherever the read ends.
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(source, os.PathLike):
|
|
52
|
+
source = os.fspath(source)
|
|
53
|
+
if isinstance(source, str):
|
|
54
|
+
with open(source, "rb") as fh:
|
|
55
|
+
data = fh.read()
|
|
56
|
+
stem = os.path.splitext(os.path.basename(source))[0]
|
|
57
|
+
return data, source, stem or "document"
|
|
58
|
+
if isinstance(source, (bytes, bytearray)):
|
|
59
|
+
return bytes(source), None, None
|
|
60
|
+
read = getattr(source, "read", None)
|
|
61
|
+
if callable(read):
|
|
62
|
+
data = read()
|
|
63
|
+
if isinstance(data, bytearray):
|
|
64
|
+
data = bytes(data)
|
|
65
|
+
if not isinstance(data, bytes):
|
|
66
|
+
raise TypeError(
|
|
67
|
+
"file-like sources must read bytes, "
|
|
68
|
+
f"not {type(data).__name__}"
|
|
69
|
+
)
|
|
70
|
+
return data, None, None
|
|
71
|
+
raise TypeError(
|
|
72
|
+
"source must be a path, bytes or a file-like object, "
|
|
73
|
+
f"not {type(source).__name__}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _kind(data: bytes) -> str:
|
|
78
|
+
"""``"pdf"``, ``"office"`` or ``"unknown"``, by magic bytes.
|
|
79
|
+
|
|
80
|
+
The extension is never consulted: a workbook saved as `.pdf` extracts as
|
|
81
|
+
a workbook, and a PDF saved as `.bin` extracts as a PDF. The PDF header
|
|
82
|
+
is searched in the first 1024 bytes rather than prefix-matched — the PDF
|
|
83
|
+
spec tolerates leading garbage there. The ZIP match stays a prefix match:
|
|
84
|
+
Office files open with the local file header, and a PDF must win over a
|
|
85
|
+
ZIP that merely contains the header bytes further in.
|
|
86
|
+
"""
|
|
87
|
+
if b"%PDF-" in data[:1024]:
|
|
88
|
+
return "pdf"
|
|
89
|
+
if data.startswith(b"PK"):
|
|
90
|
+
return "office"
|
|
91
|
+
return "unknown"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_office() -> Any:
|
|
95
|
+
extract_office = getattr(pdfluent, "extract_office", None)
|
|
96
|
+
if extract_office is None:
|
|
97
|
+
raise NotImplementedError(
|
|
98
|
+
"Office files need pdfluent>=1.1.0, which exposes "
|
|
99
|
+
"pdfluent.extract_office; the installed pdfluent "
|
|
100
|
+
f"{getattr(pdfluent, '__version__', 'unknown')} does not have it"
|
|
101
|
+
)
|
|
102
|
+
return extract_office
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _check_pages_for(pages: Optional[Sequence[int]], kind: str) -> None:
|
|
106
|
+
if pages is not None and kind != "pdf":
|
|
107
|
+
raise ValueError("pages selects PDF pages; Office files have no pages to select")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def to_markdown(
|
|
111
|
+
source: Source,
|
|
112
|
+
*,
|
|
113
|
+
quality: str = "fast",
|
|
114
|
+
pages: Optional[Sequence[int]] = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""The file as Markdown, in reading order.
|
|
117
|
+
|
|
118
|
+
``source`` is a path, ``bytes`` or a file-like object; PDF and Office
|
|
119
|
+
(``.docx``/``.pptx``/``.xlsx``) are routed by magic bytes. ``pages`` is a
|
|
120
|
+
list of 0-based PDF page indices (``None`` for the whole file).
|
|
121
|
+
"""
|
|
122
|
+
_check_quality(quality)
|
|
123
|
+
data, _, _ = _read_source(source)
|
|
124
|
+
kind = _kind(data)
|
|
125
|
+
_check_pages_for(pages, kind)
|
|
126
|
+
if kind == "pdf":
|
|
127
|
+
with pdfluent.Document(data) as doc:
|
|
128
|
+
return doc.extract_markdown(
|
|
129
|
+
pages=list(pages) if pages is not None else None
|
|
130
|
+
)
|
|
131
|
+
if kind == "office":
|
|
132
|
+
return _require_office()(data, format="markdown")
|
|
133
|
+
raise pdfluent.PdfluentParseError(
|
|
134
|
+
"not a PDF or Office file: the bytes start with neither %PDF- nor a ZIP header"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def to_json(
|
|
139
|
+
source: Source,
|
|
140
|
+
*,
|
|
141
|
+
quality: str = "fast",
|
|
142
|
+
pages: Optional[Sequence[int]] = None,
|
|
143
|
+
) -> Dict[str, Any]:
|
|
144
|
+
"""The file as a Docling ``DoclingDocument``, as a dict.
|
|
145
|
+
|
|
146
|
+
Same routing as :func:`to_markdown`; the JSON is what
|
|
147
|
+
``DoclingDocument.model_validate_json`` reads. ``name`` is the file's stem
|
|
148
|
+
for path inputs, ``"document"`` otherwise.
|
|
149
|
+
"""
|
|
150
|
+
_check_quality(quality)
|
|
151
|
+
data, _, name = _read_source(source)
|
|
152
|
+
kind = _kind(data)
|
|
153
|
+
_check_pages_for(pages, kind)
|
|
154
|
+
if kind == "pdf":
|
|
155
|
+
with pdfluent.Document(data) as doc:
|
|
156
|
+
return json.loads(
|
|
157
|
+
doc.extract_docling(
|
|
158
|
+
name=name or "document",
|
|
159
|
+
pages=list(pages) if pages is not None else None,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
if kind == "office":
|
|
163
|
+
return json.loads(
|
|
164
|
+
_require_office()(data, format="docling", name=name or "document")
|
|
165
|
+
)
|
|
166
|
+
raise pdfluent.PdfluentParseError(
|
|
167
|
+
"not a PDF or Office file: the bytes start with neither %PDF- nor a ZIP header"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def to_chunks(
|
|
172
|
+
source: Source,
|
|
173
|
+
*,
|
|
174
|
+
quality: str = "fast",
|
|
175
|
+
max_tokens: int = 512,
|
|
176
|
+
pages: Optional[Sequence[int]] = None,
|
|
177
|
+
) -> List[Chunk]:
|
|
178
|
+
"""The file as RAG chunks, in reading order.
|
|
179
|
+
|
|
180
|
+
One chunk per paragraph, heading and list item, and one per table (a cell
|
|
181
|
+
on its own is a number without its column). Every chunk carries ``page``,
|
|
182
|
+
``section_path`` (the enclosing headings) and ``bbox`` in its metadata,
|
|
183
|
+
plus ``source`` for path inputs.
|
|
184
|
+
|
|
185
|
+
``max_tokens`` budgets each chunk (about four characters to the token);
|
|
186
|
+
tables longer than the budget split by rows with the header repeated.
|
|
187
|
+
``pages`` lists 0-based page indices; chunks come back per page in the
|
|
188
|
+
listed order. PDF only in v1.
|
|
189
|
+
"""
|
|
190
|
+
_check_quality(quality)
|
|
191
|
+
if not isinstance(max_tokens, int) or max_tokens < 1:
|
|
192
|
+
raise ValueError(f"max_tokens must be a positive int, not {max_tokens!r}")
|
|
193
|
+
data, path, _ = _read_source(source)
|
|
194
|
+
kind = _kind(data)
|
|
195
|
+
if kind != "pdf":
|
|
196
|
+
if kind == "office":
|
|
197
|
+
raise NotImplementedError(
|
|
198
|
+
"to_chunks reads PDF files in v1; Office chunking is not scheduled yet"
|
|
199
|
+
)
|
|
200
|
+
raise pdfluent.PdfluentParseError(
|
|
201
|
+
"not a PDF or Office file: the bytes start with neither %PDF- nor a ZIP header"
|
|
202
|
+
)
|
|
203
|
+
max_chars = max_tokens * CHARS_PER_TOKEN
|
|
204
|
+
chunks: List[Chunk] = []
|
|
205
|
+
with pdfluent.Document(data) as doc:
|
|
206
|
+
if pages is None:
|
|
207
|
+
chunks = list(iter_chunks(doc, hierarchical=True, max_chars=max_chars))
|
|
208
|
+
else:
|
|
209
|
+
for page in pages:
|
|
210
|
+
chunks.extend(
|
|
211
|
+
iter_chunks(
|
|
212
|
+
doc,
|
|
213
|
+
hierarchical=True,
|
|
214
|
+
max_chars=max_chars,
|
|
215
|
+
first_page=page,
|
|
216
|
+
page_count=1,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
if path is not None:
|
|
220
|
+
for chunk in chunks:
|
|
221
|
+
chunk.metadata = {"source": path, **chunk.metadata}
|
|
222
|
+
return chunks
|
pdfluent_extract/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Copyright (c) 2026 Innovation Trigger B.V.
|
|
2
|
+
#
|
|
3
|
+
# PDFluent is available under two licences, at your option: the GNU AGPLv3, or
|
|
4
|
+
# the PDFluent Commercial Licence. See the LICENSE file in this repository --
|
|
5
|
+
# that file travels with the copy you received, which a URL does not.
|
|
6
|
+
"""The `pdfluent-extract` console script: files to Markdown, JSON and chunks.
|
|
7
|
+
|
|
8
|
+
`pdfluent-extract <input> [-o OUT] [--format md|json|chunks|text]
|
|
9
|
+
[--quality fast|balanced|best] [--pages SPEC]`
|
|
10
|
+
|
|
11
|
+
`-` as input reads stdin; `-o -` or no `-o` writes stdout. `--pages` counts
|
|
12
|
+
from 1 (`1-3,5`), like the main CLI. Exit 2 on a usage error, 1 on an
|
|
13
|
+
extraction error, with a message on stderr either way.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import asdict, is_dataclass
|
|
22
|
+
from typing import List, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
from pdfluent_extract import to_chunks, to_json, to_markdown
|
|
25
|
+
|
|
26
|
+
FORMATS = ("md", "json", "chunks", "text")
|
|
27
|
+
QUALITIES = ("fast", "balanced", "best")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_pages(spec: str) -> List[int]:
|
|
31
|
+
"""`1-3,5` into sorted, deduplicated 0-based page indices.
|
|
32
|
+
|
|
33
|
+
Users count from 1; the engine counts from 0. A bad spec raises
|
|
34
|
+
ValueError naming it — the caller reports that as a usage error.
|
|
35
|
+
"""
|
|
36
|
+
items = spec.split(",")
|
|
37
|
+
pages: List[int] = []
|
|
38
|
+
for raw in items:
|
|
39
|
+
item = raw.strip()
|
|
40
|
+
if not item:
|
|
41
|
+
raise ValueError(f"empty item in page spec {spec!r} (want e.g. `1-3,5`)")
|
|
42
|
+
if "-" in item:
|
|
43
|
+
lo_text, _, hi_text = item.partition("-")
|
|
44
|
+
try:
|
|
45
|
+
lo, hi = int(lo_text), int(hi_text)
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise ValueError(f"not a page range: {item!r} (in {spec!r})")
|
|
48
|
+
if lo < 1 or hi < 1:
|
|
49
|
+
raise ValueError(f"pages count from 1, so `0` names nothing (in {spec!r})")
|
|
50
|
+
if lo > hi:
|
|
51
|
+
raise ValueError(f"reversed range {item!r} (in {spec!r}; want `lo-hi`)")
|
|
52
|
+
pages.extend(range(lo - 1, hi))
|
|
53
|
+
else:
|
|
54
|
+
try:
|
|
55
|
+
page = int(item)
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise ValueError(f"not a page number: {item!r} (in {spec!r})")
|
|
58
|
+
if page < 1:
|
|
59
|
+
raise ValueError(f"pages count from 1, so `0` names nothing (in {spec!r})")
|
|
60
|
+
pages.append(page - 1)
|
|
61
|
+
return sorted(set(pages))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_input(path: str):
|
|
65
|
+
"""The input as the library wants it: a path stays a path (so chunks
|
|
66
|
+
cite it and JSON names it), `-` reads stdin as bytes."""
|
|
67
|
+
if path == "-":
|
|
68
|
+
data = sys.stdin.buffer.read()
|
|
69
|
+
if not data:
|
|
70
|
+
raise ValueError("nothing on stdin")
|
|
71
|
+
return data
|
|
72
|
+
with open(path, "rb"):
|
|
73
|
+
pass
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _render(
|
|
78
|
+
source,
|
|
79
|
+
*,
|
|
80
|
+
format: str,
|
|
81
|
+
quality: str,
|
|
82
|
+
pages: Optional[Sequence[int]],
|
|
83
|
+
) -> str:
|
|
84
|
+
if format == "md":
|
|
85
|
+
return to_markdown(source, quality=quality, pages=pages)
|
|
86
|
+
if format == "json":
|
|
87
|
+
return json.dumps(to_json(source, quality=quality, pages=pages), indent=2)
|
|
88
|
+
chunks = to_chunks(source, quality=quality, pages=pages)
|
|
89
|
+
if format == "text":
|
|
90
|
+
return "\n\n".join(chunk.text for chunk in chunks)
|
|
91
|
+
return json.dumps(
|
|
92
|
+
[
|
|
93
|
+
asdict(chunk) if is_dataclass(chunk) else chunk
|
|
94
|
+
for chunk in chunks
|
|
95
|
+
],
|
|
96
|
+
indent=2,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _write_output(text: str, out: Optional[str]) -> None:
|
|
101
|
+
if not text.endswith("\n"):
|
|
102
|
+
text += "\n"
|
|
103
|
+
if out is None or out == "-":
|
|
104
|
+
sys.stdout.write(text)
|
|
105
|
+
return
|
|
106
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
107
|
+
fh.write(text)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
111
|
+
parser = argparse.ArgumentParser(
|
|
112
|
+
prog="pdfluent-extract",
|
|
113
|
+
description="PDF and Office files to Markdown, JSON and RAG chunks.",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument("input", help="input file, or - for stdin")
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"-o", "--output", default=None,
|
|
118
|
+
help="write here instead of stdout (- for stdout)",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--format", choices=FORMATS, default="md",
|
|
122
|
+
help="output shape (default: md)",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--quality", choices=QUALITIES, default="fast",
|
|
126
|
+
help="extraction quality (default: fast)",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--pages", default=None, metavar="SPEC",
|
|
130
|
+
help="pages to extract, counting from 1 (e.g. 1-3,5)",
|
|
131
|
+
)
|
|
132
|
+
return parser
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
136
|
+
args = build_parser().parse_args(argv)
|
|
137
|
+
try:
|
|
138
|
+
pages = parse_pages(args.pages) if args.pages is not None else None
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
print(f"pdfluent-extract: {e}", file=sys.stderr)
|
|
141
|
+
return 2
|
|
142
|
+
try:
|
|
143
|
+
source = _read_input(args.input)
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
print(f"pdfluent-extract: no such file: {args.input}", file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
except OSError as e:
|
|
148
|
+
print(f"pdfluent-extract: cannot read {args.input}: {e}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
except ValueError as e:
|
|
151
|
+
print(f"pdfluent-extract: {e}", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
try:
|
|
154
|
+
text = _render(source, format=args.format, quality=args.quality, pages=pages)
|
|
155
|
+
except Exception as e: # the engine refusing input it cannot read
|
|
156
|
+
print(f"pdfluent-extract: extraction failed: {e}", file=sys.stderr)
|
|
157
|
+
return 1
|
|
158
|
+
try:
|
|
159
|
+
_write_output(text, args.output)
|
|
160
|
+
except OSError as e:
|
|
161
|
+
print(f"pdfluent-extract: cannot write {args.output}: {e}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pdfluent-extract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PDF to Markdown, JSON and RAG chunks — fast, local, no system dependencies
|
|
5
|
+
Project-URL: Homepage, https://pdfluent.com
|
|
6
|
+
Project-URL: Documentation, https://pdfluent.com/docs
|
|
7
|
+
Project-URL: Pricing, https://pdfluent.com/sdk/pricing/
|
|
8
|
+
Project-URL: Source, https://github.com/pdfluent/pdfluent-sdk
|
|
9
|
+
Project-URL: Changelog, https://github.com/pdfluent/pdfluent-sdk/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/pdfluent/pdfluent-sdk/issues
|
|
11
|
+
Author-email: "Innovation Trigger B.V." <team@pdfluent.com>
|
|
12
|
+
License-Expression: AGPL-3.0-only OR LicenseRef-PDFluent-Commercial
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
License-File: LICENSE-COMMERCIAL
|
|
15
|
+
Keywords: chunking,docling,langchain,llamaindex,llm,markdown,pdf,pdf-to-text,rag,table-extraction
|
|
16
|
+
Classifier: Development Status :: 4 - Beta
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
20
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
29
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
30
|
+
Classifier: Topic :: Text Processing
|
|
31
|
+
Requires-Python: >=3.8
|
|
32
|
+
Requires-Dist: pdfluent<2,>=1.1.0
|
|
33
|
+
Provides-Extra: langchain
|
|
34
|
+
Requires-Dist: langchain-pdfluent>=0.1; extra == 'langchain'
|
|
35
|
+
Provides-Extra: llamaindex
|
|
36
|
+
Requires-Dist: llama-index-readers-pdfluent>=0.1; extra == 'llamaindex'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# pdfluent-extract
|
|
40
|
+
|
|
41
|
+
PDF and Office files to Markdown, JSON and RAG chunks, in three calls. It is
|
|
42
|
+
pure Python: the engine underneath is
|
|
43
|
+
[`pdfluent`](https://pypi.org/project/pdfluent/), which pip pulls in
|
|
44
|
+
automatically. There is nothing to compile and no system dependency to
|
|
45
|
+
install — on Linux arm64 and Alpine/musl as much as anywhere else, because
|
|
46
|
+
`pdfluent` 1.1.0 ships a wheel for every platform in the matrix below.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install pdfluent-extract
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It needs Python 3.8 or later and `pdfluent` 1.1.0 or later.
|
|
55
|
+
|
|
56
|
+
## Use
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pdfluent_extract import to_markdown
|
|
60
|
+
|
|
61
|
+
print(to_markdown("report.pdf"))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`to_markdown` takes a path, `bytes` or a file-like object, and routes PDF and
|
|
65
|
+
Office (`.docx`/`.pptx`/`.xlsx`) by magic bytes, not by the extension. `pages`
|
|
66
|
+
lists 0-based PDF page indices (`None` for the whole file).
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pdfluent_extract import to_json
|
|
70
|
+
|
|
71
|
+
doc = to_json("report.pdf")
|
|
72
|
+
print(doc["name"], len(doc["texts"]))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`to_json` returns the file as a Docling `DoclingDocument`, as a dict — the
|
|
76
|
+
same JSON `DoclingDocument.model_validate_json` reads.
|
|
77
|
+
|
|
78
|
+
## Command line
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
$ pdfluent-extract report.pdf -o report.md
|
|
82
|
+
$ pdfluent-extract report.pdf --format json --pages 1-3 > doc.json
|
|
83
|
+
$ cat report.pdf | pdfluent-extract - --format text
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`--format` is one of `md`, `json`, `chunks` or `text`; `--pages` counts
|
|
87
|
+
from 1 (`1-3,5`). Without `-o` (or with `-o -`) the output goes to stdout.
|
|
88
|
+
|
|
89
|
+
## Chunks for RAG
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pdfluent_extract import to_chunks
|
|
93
|
+
|
|
94
|
+
for chunk in to_chunks("report.pdf", max_tokens=512):
|
|
95
|
+
print(chunk.metadata["page"], chunk.metadata["section_path"], chunk.text[:80])
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
One chunk per paragraph, heading and list item, and one per table. Every chunk
|
|
99
|
+
carries `page`, `section_path` (the enclosing headings) and `bbox` in its
|
|
100
|
+
metadata, plus `source` for path inputs. Tables longer than the budget split
|
|
101
|
+
by rows with the header repeated. PDF only in v1.
|
|
102
|
+
|
|
103
|
+
## With LangChain and LlamaIndex
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install "pdfluent-extract[langchain]"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from langchain_pdfluent import PDFluentLoader
|
|
111
|
+
|
|
112
|
+
docs = PDFluentLoader("report.pdf").load()
|
|
113
|
+
print(docs[0].page_content, docs[0].metadata["bbox"])
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pip install "pdfluent-extract[llamaindex]"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from llama_index.readers.pdfluent import PDFluentReader
|
|
122
|
+
|
|
123
|
+
docs = PDFluentReader().load_data("report.pdf")
|
|
124
|
+
print(docs[0].text[:200])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Supported platforms
|
|
128
|
+
|
|
129
|
+
Every platform `pdfluent` 1.1.0 ships a wheel for: macOS (universal2),
|
|
130
|
+
Windows x64, Linux x86_64 and arm64 on glibc (manylinux2014) and musl
|
|
131
|
+
(musllinux_1_2) — Python 3.8 through 3.13 from one abi3 wheel per platform.
|
|
132
|
+
|
|
133
|
+
## Quality
|
|
134
|
+
|
|
135
|
+
`quality="fast"` is the only level in v1; anything else is refused by name.
|
|
136
|
+
Further levels (vision/OCR) are designed later.
|
|
137
|
+
|
|
138
|
+
## Licence
|
|
139
|
+
|
|
140
|
+
AGPL-3.0 or a commercial licence, at your option; see `LICENSE` and
|
|
141
|
+
`LICENSE-COMMERCIAL`. The commercial licence is sold yearly and self-service
|
|
142
|
+
at <https://pdfluent.com/sdk/pricing/>.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pdfluent_extract/__init__.py,sha256=BrtwtqtV5n7gYZaB70ITmtMzPY0Ve8Cb9A4bLS_O9iM,8055
|
|
2
|
+
pdfluent_extract/cli.py,sha256=u0FMsEeBXtf7u2Ueah5ptl8Si9q3bcma32rQhEEEitQ,5617
|
|
3
|
+
pdfluent_extract-0.1.0.dist-info/METADATA,sha256=lNyc6T_ImIQoqzulQtLuopxZ-ubAfCpeoQn7GckUQwI,4812
|
|
4
|
+
pdfluent_extract-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
pdfluent_extract-0.1.0.dist-info/entry_points.txt,sha256=-IEo7RLPQhDUMdHhaGANvJFxlqEUmxDJYapk9apJrQ8,63
|
|
6
|
+
pdfluent_extract-0.1.0.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
|
|
7
|
+
pdfluent_extract-0.1.0.dist-info/licenses/LICENSE-COMMERCIAL,sha256=ylTZq0bZnoHQ3S6m7REvPv-n-HzTtlqHaxdAye_lme0,10026
|
|
8
|
+
pdfluent_extract-0.1.0.dist-info/RECORD,,
|