pdfpecker 1.23.0__py3-none-win_amd64.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.
pdfpecker/__init__.py ADDED
@@ -0,0 +1,326 @@
1
+ """PdfPecker: fast HTML and Markdown to PDF, and PDF to images and text, with no browser and no dependencies.
2
+
3
+ import pdfpecker
4
+
5
+ pdf = pdfpecker.html_to_pdf("<h1>Invoice</h1><p>Total: 42 EUR</p>")
6
+ pdf = pdfpecker.html_file_to_pdf("templates/invoice.html", "invoice.pdf")
7
+ pngs = pdfpecker.pdf_to_images(pdf, dpi=96)
8
+ text = pdfpecker.pdf_to_text(pdf)
9
+
10
+ PdfPecker is a .NET library compiled ahead of time into a native library that ships inside the wheel, so no .NET
11
+ runtime is needed. It runs no JavaScript and never goes online: images, stylesheets and fonts load from base_dir.
12
+ Every function is thread-safe and releases the GIL while it works.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import datetime as _dt
17
+ import json
18
+ import os
19
+ from typing import Iterable, List, Optional, Sequence, Tuple, Union
20
+
21
+ from . import _native
22
+ from ._errors import (PdfFileError, PdfLimitError, PdfPasswordError, PdfPeckerArgumentError, PdfPeckerError,
23
+ PdfReadError, PdfTimeoutError)
24
+
25
+ __all__ = [
26
+ "html_to_pdf", "html_file_to_pdf", "markdown_to_pdf", "markdown_file_to_pdf",
27
+ "pdf_to_images", "pdf_page_count", "pdf_to_text", "pdf_to_text_pages", "pdf_to_markdown", "merge_pdfs",
28
+ "pdf_to_pdfa", "PdfAResult",
29
+ "library_version", "__version__",
30
+ "PdfPeckerError", "PdfPeckerArgumentError", "PdfReadError", "PdfPasswordError", "PdfLimitError",
31
+ "PdfTimeoutError", "PdfFileError",
32
+ ]
33
+
34
+ try:
35
+ from importlib.metadata import version as _dist_version
36
+
37
+ __version__ = _dist_version("pdfpecker")
38
+ except Exception: # running from a source checkout
39
+ __version__ = "0+unknown"
40
+
41
+ PathLike = Union[str, "os.PathLike[str]"]
42
+ PageSize = Union[str, Tuple[float, float]]
43
+ Margin = Union[float, Tuple[float, float, float, float]]
44
+ Date = Union[_dt.datetime, _dt.date, int, float]
45
+
46
+
47
+ def library_version() -> str:
48
+ """The version of the PdfPecker .NET library inside this package."""
49
+ return _native.version()
50
+
51
+
52
+ def html_to_pdf(
53
+ html: Union[str, bytes],
54
+ *,
55
+ base_dir: Optional[PathLike] = None,
56
+ page_size: Optional[PageSize] = None,
57
+ landscape: bool = False,
58
+ margin: Optional[Margin] = None,
59
+ scale: Optional[float] = None,
60
+ shrink_to_fit: Optional[bool] = None,
61
+ title: Optional[str] = None,
62
+ author: Optional[str] = None,
63
+ subject: Optional[str] = None,
64
+ keywords: Optional[str] = None,
65
+ language: Optional[str] = None,
66
+ header_text: Optional[str] = None,
67
+ footer_text: Optional[str] = None,
68
+ user_style_sheet: Optional[str] = None,
69
+ fonts: Optional[Iterable[PathLike]] = None,
70
+ use_system_fonts: bool = False,
71
+ allow_file_access: bool = False,
72
+ compress: bool = True,
73
+ bookmarks: bool = True,
74
+ max_pages: Optional[int] = None,
75
+ creation_date: Optional[Date] = None,
76
+ conformance: Optional[str] = None,
77
+ accessibility: Optional[str] = None,
78
+ timeout: Optional[float] = None,
79
+ ) -> bytes:
80
+ """Converts HTML to a PDF and returns its bytes.
81
+
82
+ html: the document, as str or UTF-8 bytes. CSS (including @page rules for size, margins, headers and footers)
83
+ comes from <style> and <link rel=stylesheet>.
84
+ base_dir: the folder that relative image, stylesheet and font paths resolve against. Without it, a document from a
85
+ string loads no local files. Files outside it load only with allow_file_access=True.
86
+ page_size: "A3", "A4" (default), "A5", "Letter", "Legal" or (width_mm, height_mm); @page size overrides it.
87
+ landscape: turns page_size sideways.
88
+ margin: page margins in points (72 per inch): one number or (top, right, bottom, left). Default 36 (half an inch).
89
+ scale: zooms the content; shrink_to_fit (default True) shrinks content wider than the page.
90
+ title, author, subject, keywords: document properties. language: the document language, such as "en-GB".
91
+ header_text, footer_text: a line of text at the top or bottom of every page; {page} and {pages} are replaced,
92
+ as in "Page {page} of {pages}".
93
+ user_style_sheet: CSS applied after the document's own.
94
+ fonts: TrueType/OpenType files or folders that CSS font-family can name. Without fonts, text uses the standard PDF
95
+ fonts, which cover Western European characters only; add fonts for other scripts.
96
+ use_system_fonts: also look up font families among the fonts installed on this machine.
97
+ compress: compress the PDF's streams. bookmarks: make bookmarks from the headings.
98
+ max_pages: stop with PdfLimitError beyond this many pages (default 100,000).
99
+ creation_date: a fixed creation date, which makes the output byte-identical from run to run.
100
+ conformance: "PDF/A-2b", "PDF/A-3b", "PDF/A-2u", "PDF/A-3u", "PDF/A-4" or "PDF/A-4f".
101
+ accessibility: "tagged", "PDF/UA-1" or "PDF/UA-2".
102
+ timeout: seconds before the conversion stops with PdfTimeoutError.
103
+ """
104
+ data = html.encode("utf-8") if isinstance(html, str) else bytes(html)
105
+ return _native.invoke("html_to_pdf", data, _pdf_options(locals()))
106
+
107
+
108
+ def html_file_to_pdf(path: PathLike, output: Optional[PathLike] = None, **options) -> bytes:
109
+ """Converts an HTML file; relative paths resolve against its folder. Writes the PDF to output when given, and
110
+ returns its bytes. Takes the keyword options of html_to_pdf."""
111
+ options.setdefault("base_dir", os.path.dirname(os.path.abspath(path)))
112
+ with open(path, "rb") as f:
113
+ pdf = html_to_pdf(f.read(), **options)
114
+ return _write(pdf, output)
115
+
116
+
117
+ def markdown_to_pdf(markdown: Union[str, bytes], **options) -> bytes:
118
+ """Converts Markdown (CommonMark with GitHub tables, task lists, strikethrough and footnotes) to a PDF. Takes the
119
+ keyword options of html_to_pdf."""
120
+ data = markdown.encode("utf-8") if isinstance(markdown, str) else bytes(markdown)
121
+ return _native.invoke("markdown_to_pdf", data, _pdf_options(_check(options, html_to_pdf)))
122
+
123
+
124
+ def markdown_file_to_pdf(path: PathLike, output: Optional[PathLike] = None, **options) -> bytes:
125
+ """Converts a Markdown file; images resolve against its folder. Writes the PDF to output when given."""
126
+ options.setdefault("base_dir", os.path.dirname(os.path.abspath(path)))
127
+ with open(path, "rb") as f:
128
+ pdf = markdown_to_pdf(f.read(), **options)
129
+ return _write(pdf, output)
130
+
131
+
132
+ def pdf_to_images(
133
+ pdf: Union[bytes, PathLike],
134
+ *,
135
+ dpi: float = 150,
136
+ format: str = "png",
137
+ pages: Optional[Union[str, int, Sequence[int]]] = None,
138
+ width: Optional[int] = None,
139
+ height: Optional[int] = None,
140
+ transparent: bool = False,
141
+ quality: Optional[int] = None,
142
+ password: Optional[str] = None,
143
+ timeout: Optional[float] = None,
144
+ ) -> List[bytes]:
145
+ """Renders PDF pages to images and returns one encoded image per page.
146
+
147
+ pdf: the PDF's bytes or a path to it.
148
+ format: "png" (default), "jpeg" or "webp"; quality 1-100 for jpeg and webp.
149
+ pages: which pages, numbered from 1: an int, a list, or a range such as "1-3,5" or "last". Default: all.
150
+ dpi: resolution (default 150); width or height instead fix the size in pixels, keeping the aspect ratio.
151
+ transparent: leave the page background transparent (png and webp).
152
+ """
153
+ return _native.unframe(_native.invoke("pdf_to_images", _pdf_bytes(pdf), {
154
+ "dpi": dpi, "format": format, "pages": _pages(pages), "width": width, "height": height,
155
+ "transparent": transparent, "quality": quality, "password": password, "timeout": timeout}))
156
+
157
+
158
+ def pdf_page_count(pdf: Union[bytes, PathLike], *, password: Optional[str] = None) -> int:
159
+ """The number of pages of a PDF."""
160
+ return int(_native.invoke("pdf_page_count", _pdf_bytes(pdf), {"password": password}))
161
+
162
+
163
+ def pdf_to_text_pages(
164
+ pdf: Union[bytes, PathLike],
165
+ *,
166
+ pages: Optional[Union[str, int, Sequence[int]]] = None,
167
+ password: Optional[str] = None,
168
+ timeout: Optional[float] = None,
169
+ ) -> List[str]:
170
+ """Extracts the text of each page, in reading order, as one string per page."""
171
+ raw = _native.invoke("pdf_to_text", _pdf_bytes(pdf), {"pages": _pages(pages), "password": password, "timeout": timeout})
172
+ return [page.decode("utf-8") for page in _native.unframe(raw)]
173
+
174
+
175
+ def pdf_to_text(pdf: Union[bytes, PathLike], **options) -> str:
176
+ """Extracts the text of a PDF, pages separated by a form feed. Takes the options of pdf_to_text_pages."""
177
+ return "\f".join(pdf_to_text_pages(pdf, **options))
178
+
179
+
180
+ def pdf_to_markdown(
181
+ pdf: Union[bytes, PathLike],
182
+ *,
183
+ pages: Optional[Union[str, int, Sequence[int]]] = None,
184
+ include_links: bool = True,
185
+ include_annotations: bool = True,
186
+ include_images: bool = False,
187
+ page_comments: bool = False,
188
+ max_pages: Optional[int] = None,
189
+ password: Optional[str] = None,
190
+ timeout: Optional[float] = None,
191
+ ) -> str:
192
+ """Converts a PDF, any PDF and not only one PdfPecker made, to GitHub Flavored Markdown: headings, paragraphs,
193
+ lists and tables in reading order, for search indexes and language models.
194
+
195
+ A tagged PDF is read from its structure tree, other PDFs from their layout. Drawings and the text of scanned pages
196
+ are not part of the output.
197
+ include_links: text that links to a web or email address becomes a Markdown link.
198
+ include_annotations: include the text of annotations, such as form field values.
199
+ include_images: include pictures as data: URI images where they sit in the text (off by default: they are long).
200
+ page_comments: start each page with an HTML comment such as <!-- page 3 -->, so a passage can be traced to its page.
201
+ max_pages: stop with PdfLimitError beyond this many pages (default 10,000).
202
+ """
203
+ return _native.invoke("pdf_to_markdown", _pdf_bytes(pdf), {
204
+ "pages": _pages(pages), "include_links": include_links, "include_annotations": include_annotations,
205
+ "include_images": include_images, "page_comments": page_comments, "max_pages": max_pages,
206
+ "password": password, "timeout": timeout}).decode("utf-8")
207
+
208
+
209
+ class PdfAResult:
210
+ """The outcome of pdf_to_pdfa: the converted PDF, what the conversion changed and what still breaks the rules."""
211
+
212
+ __slots__ = ("pdf", "changes", "problems")
213
+
214
+ def __init__(self, pdf: bytes, changes: List[str], problems: List[str]) -> None:
215
+ self.pdf = pdf
216
+ self.changes = changes
217
+ self.problems = problems
218
+
219
+ @property
220
+ def is_conformant(self) -> bool:
221
+ """True when the conversion found nothing left that breaks the PDF/A rules it checks; veraPDF has the final
222
+ word."""
223
+ return not self.problems
224
+
225
+ def __repr__(self) -> str:
226
+ return (f"PdfAResult(pdf=<{len(self.pdf)} bytes>, changes={len(self.changes)}, "
227
+ f"problems={len(self.problems)})")
228
+
229
+
230
+ def pdf_to_pdfa(
231
+ pdf: Union[bytes, PathLike],
232
+ output: Optional[PathLike] = None,
233
+ *,
234
+ conformance: str = "PDF/A-2b",
235
+ timeout: Optional[float] = None,
236
+ ) -> PdfAResult:
237
+ """Converts an existing PDF, any PDF and not only one PdfPecker made, to PDF/A for long-term archiving.
238
+
239
+ Fonts that are not embedded are embedded, colours made device independent, and JavaScript and other things PDF/A
240
+ forbids removed; text stays text. Writes the PDF to output when given.
241
+ conformance: "PDF/A-2b" (default), "PDF/A-2u", "PDF/A-3b" or "PDF/A-3u". PDF/A-2 keeps only attachments that are
242
+ PDF/A files themselves; PDF/A-3 keeps every attachment.
243
+ Returns a PdfAResult whose changes say what was changed and whose problems say what could not be fixed. Encrypted
244
+ and signed PDFs raise PdfPeckerArgumentError.
245
+ """
246
+ raw = _native.invoke("pdf_to_pdfa", _pdf_bytes(pdf), {"conformance": conformance, "timeout": timeout})
247
+ converted, report = _native.unframe(raw)
248
+ details = json.loads(report.decode("utf-8"))
249
+ _write(converted, output)
250
+ return PdfAResult(converted, details["changes"], details["problems"])
251
+
252
+
253
+ def merge_pdfs(
254
+ pdfs: Iterable[Union[bytes, PathLike]],
255
+ *,
256
+ title: Optional[str] = None,
257
+ author: Optional[str] = None,
258
+ bookmarks: bool = True,
259
+ creation_date: Optional[Date] = None,
260
+ timeout: Optional[float] = None,
261
+ ) -> bytes:
262
+ """Merges PDFs, given as bytes or paths, into one, keeping links, bookmarks and form fields."""
263
+ items = [_pdf_bytes(p) for p in pdfs]
264
+ return _native.invoke("merge_pdfs", _native.frame(items), {
265
+ "title": title, "author": author, "bookmarks": bookmarks, "creation_date": _date(creation_date),
266
+ "timeout": timeout})
267
+
268
+
269
+ def _pdf_options(args: dict) -> dict:
270
+ options = {k: v for k, v in args.items() if k not in ("html", "data")}
271
+ if options.get("base_dir") is not None:
272
+ options["base_dir"] = os.fspath(options["base_dir"])
273
+ if options.get("fonts") is not None:
274
+ options["fonts"] = [os.fspath(f) for f in options["fonts"]]
275
+ options["creation_date"] = _date(options.get("creation_date"))
276
+ if not options.get("landscape"):
277
+ options.pop("landscape", None)
278
+ return options
279
+
280
+
281
+ _PDF_OPTION_NAMES = None
282
+
283
+
284
+ def _check(options: dict, like) -> dict:
285
+ global _PDF_OPTION_NAMES
286
+ if _PDF_OPTION_NAMES is None:
287
+ code = like.__code__
288
+ _PDF_OPTION_NAMES = set(code.co_varnames[code.co_argcount:code.co_argcount + code.co_kwonlyargcount])
289
+ unknown = set(options) - _PDF_OPTION_NAMES
290
+ if unknown:
291
+ raise TypeError(f"unexpected keyword argument(s): {', '.join(sorted(unknown))}")
292
+ return options
293
+
294
+
295
+ def _date(value: Optional[Date]) -> Optional[int]:
296
+ if value is None:
297
+ return None
298
+ if isinstance(value, _dt.datetime):
299
+ if value.tzinfo is None:
300
+ value = value.replace(tzinfo=_dt.timezone.utc)
301
+ return int(value.timestamp())
302
+ if isinstance(value, _dt.date):
303
+ return int(_dt.datetime(value.year, value.month, value.day, tzinfo=_dt.timezone.utc).timestamp())
304
+ return int(value)
305
+
306
+
307
+ def _pages(pages) -> Optional[str]:
308
+ if pages is None or isinstance(pages, str):
309
+ return pages
310
+ if isinstance(pages, int):
311
+ return str(pages)
312
+ return ",".join(str(int(p)) for p in pages)
313
+
314
+
315
+ def _pdf_bytes(pdf) -> bytes:
316
+ if isinstance(pdf, (bytes, bytearray, memoryview)):
317
+ return bytes(pdf)
318
+ with open(pdf, "rb") as f:
319
+ return f.read()
320
+
321
+
322
+ def _write(pdf: bytes, output: Optional[PathLike]) -> bytes:
323
+ if output is not None:
324
+ with open(output, "wb") as f:
325
+ f.write(pdf)
326
+ return pdf
pdfpecker/__main__.py ADDED
@@ -0,0 +1,21 @@
1
+ """The pdfpecker command. Its one subcommand runs the MCP server:
2
+
3
+ pdfpecker mcp [--root DIR ...] # or: uvx pdfpecker mcp, python -m pdfpecker mcp
4
+ """
5
+ import sys
6
+ from typing import List, Optional
7
+
8
+
9
+ def main(argv: Optional[List[str]] = None) -> int:
10
+ args = sys.argv[1:] if argv is None else argv
11
+ if args[:1] == ["mcp"]:
12
+ from .mcp_server import main as mcp_main
13
+
14
+ return mcp_main(args[1:])
15
+ sys.stderr.write("usage: pdfpecker mcp [--root DIR ...]\n\n"
16
+ "Runs the PdfPecker MCP server over stdio. The Python API is documented in help(pdfpecker).\n")
17
+ return 0 if args[:1] in (["-h"], ["--help"]) else 2
18
+
19
+
20
+ if __name__ == "__main__":
21
+ sys.exit(main())
pdfpecker/_errors.py ADDED
@@ -0,0 +1,58 @@
1
+ """The exceptions PdfPecker raises. Each status of the native library maps to one class."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class PdfPeckerError(Exception):
6
+ """Base class of every PdfPecker error. status is the native library's status code."""
7
+
8
+ status = 0
9
+
10
+ def __init__(self, message: str) -> None:
11
+ super().__init__(message)
12
+
13
+
14
+ class PdfPeckerArgumentError(PdfPeckerError, ValueError):
15
+ """An argument or option is not valid (an unknown page size, a page past the end, an unknown option)."""
16
+
17
+ status = 1
18
+
19
+
20
+ class PdfReadError(PdfPeckerError):
21
+ """The input is not a readable PDF, image or font."""
22
+
23
+ status = 2
24
+
25
+
26
+ class PdfPasswordError(PdfPeckerError):
27
+ """The PDF is encrypted and no password, or a wrong one, was given."""
28
+
29
+ status = 3
30
+
31
+
32
+ class PdfLimitError(PdfPeckerError):
33
+ """A page, size or resource limit was reached, such as max_pages."""
34
+
35
+ status = 4
36
+
37
+
38
+ class PdfTimeoutError(PdfPeckerError, TimeoutError):
39
+ """The conversion took longer than its timeout."""
40
+
41
+ status = 5
42
+
43
+
44
+ class PdfFileError(PdfPeckerError, OSError):
45
+ """A file the document needs (a font, an image) could not be read."""
46
+
47
+ status = 7
48
+
49
+
50
+ _BY_STATUS = {c.status: c for c in (PdfPeckerArgumentError, PdfReadError, PdfPasswordError, PdfLimitError,
51
+ PdfTimeoutError, PdfFileError)}
52
+
53
+
54
+ def error_for(status: int, message: str) -> PdfPeckerError:
55
+ cls = _BY_STATUS.get(status, PdfPeckerError)
56
+ error = cls(message)
57
+ error.status = status
58
+ return error
pdfpecker/_native.py ADDED
@@ -0,0 +1,91 @@
1
+ """Loads the PdfPecker shared library and calls its one entry point, pdfpecker_invoke."""
2
+ from __future__ import annotations
3
+
4
+ import ctypes
5
+ import json
6
+ import os
7
+ import struct
8
+ import sys
9
+ from typing import Any, List, Sequence
10
+
11
+ from ._errors import error_for
12
+
13
+ _ABI_VERSION = 1
14
+
15
+ if sys.platform == "win32":
16
+ _LIB_NAME = "pdfpecker_native.dll"
17
+ elif sys.platform == "darwin":
18
+ _LIB_NAME = "pdfpecker_native.dylib"
19
+ else:
20
+ _LIB_NAME = "pdfpecker_native.so"
21
+
22
+
23
+ def _load() -> ctypes.CDLL:
24
+ path = os.environ.get("PDFPECKER_NATIVE_LIB") or os.path.join(os.path.dirname(os.path.abspath(__file__)), _LIB_NAME)
25
+ if not os.path.exists(path):
26
+ raise ImportError(
27
+ f"PdfPecker's native library is missing ({path}). Install a wheel built for this platform "
28
+ "(pip install pdfpecker), or set PDFPECKER_NATIVE_LIB to a pdfpecker_native library you built.")
29
+ lib = ctypes.CDLL(path)
30
+ lib.pdfpecker_invoke.argtypes = [
31
+ ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int64, ctypes.c_char_p,
32
+ ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_int64), ctypes.POINTER(ctypes.c_void_p)]
33
+ lib.pdfpecker_invoke.restype = ctypes.c_int
34
+ lib.pdfpecker_free.argtypes = [ctypes.c_void_p]
35
+ lib.pdfpecker_free.restype = None
36
+ lib.pdfpecker_version.argtypes = []
37
+ lib.pdfpecker_version.restype = ctypes.c_char_p
38
+ lib.pdfpecker_abi_version.argtypes = []
39
+ lib.pdfpecker_abi_version.restype = ctypes.c_int
40
+ abi = lib.pdfpecker_abi_version()
41
+ if abi != _ABI_VERSION:
42
+ raise ImportError(f"PdfPecker's native library speaks ABI {abi}, this package expects {_ABI_VERSION}")
43
+ return lib
44
+
45
+
46
+ _lib = _load()
47
+
48
+
49
+ def version() -> str:
50
+ return _lib.pdfpecker_version().decode("ascii")
51
+
52
+
53
+ def invoke(op: str, data: bytes, options: dict[str, Any]) -> bytes:
54
+ """Runs one operation. ctypes releases the GIL for the call, so threads convert in parallel."""
55
+ opts = {k: v for k, v in options.items() if v is not None}
56
+ output = ctypes.c_void_p()
57
+ output_len = ctypes.c_int64()
58
+ error = ctypes.c_void_p()
59
+ status = _lib.pdfpecker_invoke(
60
+ op.encode("ascii"), data, len(data), json.dumps(opts).encode("utf-8"),
61
+ ctypes.byref(output), ctypes.byref(output_len), ctypes.byref(error))
62
+ if status != 0:
63
+ message = "PdfPecker failed"
64
+ if error.value:
65
+ message = ctypes.string_at(error.value).decode("utf-8", "replace")
66
+ _lib.pdfpecker_free(error)
67
+ raise error_for(status, message)
68
+ try:
69
+ return ctypes.string_at(output.value, output_len.value)
70
+ finally:
71
+ _lib.pdfpecker_free(output)
72
+
73
+
74
+ def frame(items: Sequence[bytes]) -> bytes:
75
+ parts = [struct.pack("<i", len(items))]
76
+ for item in items:
77
+ parts.append(struct.pack("<q", len(item)))
78
+ parts.append(item)
79
+ return b"".join(parts)
80
+
81
+
82
+ def unframe(data: bytes) -> List[bytes]:
83
+ (count,) = struct.unpack_from("<i", data, 0)
84
+ items = []
85
+ at = 4
86
+ for _ in range(count):
87
+ (length,) = struct.unpack_from("<q", data, at)
88
+ at += 8
89
+ items.append(data[at:at + length])
90
+ at += length
91
+ return items