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.
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdfluent-extract = pdfluent_extract.cli:main