pdfworkbench 1.0.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.
pdftoolbox/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PDFworkbench - a toolbox for common PDF operations."""
2
+
3
+ __version__ = "1.0.0"
pdftoolbox/cli.py ADDED
@@ -0,0 +1,137 @@
1
+ """Command line interface for PDFworkbench.
2
+
3
+ Examples
4
+ --------
5
+ pdfworkbench -merge --dir ./invoices
6
+ pdfworkbench -merge --dir ./invoices --output all.pdf --recursive
7
+ pdfworkbench -split --input big.pdf
8
+ pdfworkbench -rotate --input scan.pdf --degrees 90
9
+ pdfworkbench -compress --input scan.pdf
10
+ pdfworkbench -extract --input report.pdf
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ from typing import Sequence
18
+
19
+ from . import __version__, operations
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="pdfworkbench",
25
+ description="A toolbox for common PDF operations.",
26
+ )
27
+ parser.add_argument(
28
+ "--version", action="version", version=f"PDFworkbench {__version__}"
29
+ )
30
+
31
+ action = parser.add_mutually_exclusive_group(required=True)
32
+ action.add_argument("-merge", action="store_true", help="Merge PDFs into one file.")
33
+ action.add_argument("-split", action="store_true", help="Split a PDF into pages.")
34
+ action.add_argument("-rotate", action="store_true", help="Rotate all pages.")
35
+ action.add_argument("-compress", action="store_true", help="Compress a PDF.")
36
+ action.add_argument(
37
+ "-extract", action="store_true", help="Extract text from a PDF."
38
+ )
39
+
40
+ parser.add_argument("--dir", "-d", help="Directory containing PDF files (merge).")
41
+ parser.add_argument("--input", "-i", nargs="+", help="One or more input PDF files.")
42
+ parser.add_argument("--output", "-o", help="Output file (or directory for split).")
43
+ parser.add_argument(
44
+ "--recursive",
45
+ "-r",
46
+ action="store_true",
47
+ help="Recurse into sub-directories when merging a directory.",
48
+ )
49
+ parser.add_argument(
50
+ "--degrees", type=int, default=90, help="Rotation in degrees (default: 90)."
51
+ )
52
+ parser.add_argument(
53
+ "--quality",
54
+ type=int,
55
+ default=60,
56
+ help="Image quality 1-100 for -compress (default: 60).",
57
+ )
58
+ parser.add_argument(
59
+ "--scale",
60
+ type=float,
61
+ default=1.0,
62
+ help="Image scale 0-1 for -compress, e.g. 0.5 halves resolution (default: 1.0).",
63
+ )
64
+ parser.add_argument(
65
+ "--no-stream-compression",
66
+ dest="compress_streams",
67
+ action="store_false",
68
+ help="Skip content-stream deflation during -compress.",
69
+ )
70
+ parser.add_argument(
71
+ "--ocr",
72
+ action="store_true",
73
+ help="Use OCR with Tesseract during -extract (requires Tesseract).",
74
+ )
75
+ return parser
76
+
77
+
78
+ def main(argv: Sequence[str] | None = None) -> int:
79
+ parser = build_parser()
80
+ args = parser.parse_args(argv)
81
+
82
+ try:
83
+ if args.merge:
84
+ if args.dir:
85
+ out = operations.merge_directory(
86
+ args.dir, args.output, recursive=args.recursive
87
+ )
88
+ elif args.input:
89
+ if not args.output:
90
+ parser.error("--output is required when merging with --input.")
91
+ out = operations.merge_pdfs(args.input, args.output)
92
+ else:
93
+ parser.error("-merge requires --dir or --input.")
94
+ print(f"Merged PDF written to: {out}")
95
+
96
+ elif args.split:
97
+ _require_single_input(parser, args)
98
+ created = operations.split_pdf(args.input[0], args.output)
99
+ print(f"Split into {len(created)} pages in: {created[0].parent}")
100
+
101
+ elif args.rotate:
102
+ _require_single_input(parser, args)
103
+ out = operations.rotate_pdf(args.input[0], args.degrees, args.output)
104
+ print(f"Rotated PDF written to: {out}")
105
+
106
+ elif args.compress:
107
+ _require_single_input(parser, args)
108
+ out = operations.compress_pdf(
109
+ args.input[0],
110
+ args.output,
111
+ image_quality=args.quality,
112
+ image_scale=args.scale,
113
+ compress_streams=args.compress_streams,
114
+ )
115
+ print(f"Compressed PDF written to: {out}")
116
+
117
+ elif args.extract:
118
+ _require_single_input(parser, args)
119
+ out = operations.extract_text(args.input[0], args.output, ocr=args.ocr)
120
+ print(f"Extracted text written to: {out}")
121
+
122
+ except (FileNotFoundError, NotADirectoryError, RuntimeError, ValueError) as exc:
123
+ print(f"Error: {exc}", file=sys.stderr)
124
+ return 1
125
+
126
+ return 0
127
+
128
+
129
+ def _require_single_input(
130
+ parser: argparse.ArgumentParser, args: argparse.Namespace
131
+ ) -> None:
132
+ if not args.input:
133
+ parser.error("This action requires --input <file.pdf>.")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
@@ -0,0 +1,213 @@
1
+ """Core PDF operations used by both the CLI and the web UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ from pypdf import PdfReader, PdfWriter
11
+
12
+
13
+ def _natural_sorted(paths: Iterable[Path]) -> list[Path]:
14
+ """Sort paths in a human-friendly order (file2 before file10)."""
15
+ import re
16
+
17
+ def key(p: Path):
18
+ return [
19
+ int(chunk) if chunk.isdigit() else chunk.lower()
20
+ for chunk in re.split(r"(\d+)", p.name)
21
+ ]
22
+
23
+ return sorted(paths, key=key)
24
+
25
+
26
+ def find_pdfs(directory: str | os.PathLike[str], recursive: bool = False) -> list[Path]:
27
+ """Return a naturally sorted list of PDF files in *directory*."""
28
+ root = Path(directory)
29
+ if not root.is_dir():
30
+ raise NotADirectoryError(f"Not a directory: {root}")
31
+
32
+ pattern = "**/*.pdf" if recursive else "*.pdf"
33
+ pdfs = [p for p in root.glob(pattern) if p.is_file()]
34
+ return _natural_sorted(pdfs)
35
+
36
+
37
+ def merge_pdfs(
38
+ inputs: Iterable[str | os.PathLike[str]],
39
+ output: str | os.PathLike[str],
40
+ ) -> Path:
41
+ """Merge *inputs* (in the given order) into a single PDF at *output*."""
42
+ inputs = [Path(p) for p in inputs]
43
+ if not inputs:
44
+ raise ValueError("No PDF files supplied to merge.")
45
+
46
+ writer = PdfWriter()
47
+ for pdf in inputs:
48
+ reader = PdfReader(str(pdf))
49
+ for page in reader.pages:
50
+ writer.add_page(page)
51
+
52
+ output = Path(output)
53
+ output.parent.mkdir(parents=True, exist_ok=True)
54
+ with output.open("wb") as fh:
55
+ writer.write(fh)
56
+ return output
57
+
58
+
59
+ def merge_directory(
60
+ directory: str | os.PathLike[str],
61
+ output: str | os.PathLike[str] | None = None,
62
+ recursive: bool = False,
63
+ ) -> Path:
64
+ """Merge every PDF found in *directory* into one file."""
65
+ pdfs = find_pdfs(directory, recursive=recursive)
66
+ if not pdfs:
67
+ raise FileNotFoundError(f"No PDF files found in: {directory}")
68
+
69
+ if output is None:
70
+ output = Path(directory) / "merged.pdf"
71
+ return merge_pdfs(pdfs, output)
72
+
73
+
74
+ def split_pdf(
75
+ input_pdf: str | os.PathLike[str],
76
+ output_dir: str | os.PathLike[str] | None = None,
77
+ ) -> list[Path]:
78
+ """Split *input_pdf* into one file per page. Returns the created files."""
79
+ src = Path(input_pdf)
80
+ reader = PdfReader(str(src))
81
+
82
+ out_dir = Path(output_dir) if output_dir else src.parent / f"{src.stem}_pages"
83
+ out_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ created: list[Path] = []
86
+ width = len(str(len(reader.pages)))
87
+ for index, page in enumerate(reader.pages, start=1):
88
+ writer = PdfWriter()
89
+ writer.add_page(page)
90
+ target = out_dir / f"{src.stem}_{index:0{width}d}.pdf"
91
+ with target.open("wb") as fh:
92
+ writer.write(fh)
93
+ created.append(target)
94
+ return created
95
+
96
+
97
+ def rotate_pdf(
98
+ input_pdf: str | os.PathLike[str],
99
+ degrees: int,
100
+ output: str | os.PathLike[str] | None = None,
101
+ ) -> Path:
102
+ """Rotate every page of *input_pdf* by *degrees* (multiple of 90)."""
103
+ if degrees % 90 != 0:
104
+ raise ValueError("Rotation must be a multiple of 90 degrees.")
105
+
106
+ src = Path(input_pdf)
107
+ reader = PdfReader(str(src))
108
+ writer = PdfWriter()
109
+ for page in reader.pages:
110
+ page.rotate(degrees)
111
+ writer.add_page(page)
112
+
113
+ out = Path(output) if output else src.with_name(f"{src.stem}_rotated.pdf")
114
+ with out.open("wb") as fh:
115
+ writer.write(fh)
116
+ return out
117
+
118
+
119
+ def compress_pdf(
120
+ input_pdf: str | os.PathLike[str],
121
+ output: str | os.PathLike[str] | None = None,
122
+ *,
123
+ image_quality: int = 60,
124
+ image_scale: float = 1.0,
125
+ compress_streams: bool = True,
126
+ ) -> Path:
127
+ """Compress *input_pdf* by re-encoding images and deflating streams.
128
+
129
+ ``image_quality`` is the JPEG quality (1-100) used when re-encoding images;
130
+ lower means smaller files. ``image_scale`` (0-1) optionally downsamples image
131
+ dimensions. Set ``compress_streams`` to also deflate page content streams.
132
+ """
133
+ if not 1 <= image_quality <= 100:
134
+ raise ValueError("image_quality must be between 1 and 100.")
135
+ if not 0 < image_scale <= 1:
136
+ raise ValueError("image_scale must be between 0 (exclusive) and 1.")
137
+
138
+ src = Path(input_pdf)
139
+ reader = PdfReader(str(src))
140
+ writer = PdfWriter()
141
+ writer.append(reader)
142
+
143
+ for page in writer.pages:
144
+ for img in page.images:
145
+ pil_image = img.image
146
+ if pil_image is None:
147
+ continue
148
+ if image_scale < 1.0:
149
+ new_size = (
150
+ max(1, int(pil_image.width * image_scale)),
151
+ max(1, int(pil_image.height * image_scale)),
152
+ )
153
+ pil_image = pil_image.resize(new_size)
154
+ img.replace(pil_image, quality=image_quality)
155
+ if compress_streams:
156
+ try:
157
+ page.compress_content_streams()
158
+ except Exception: # noqa: BLE001 - some streams can't be re-deflated
159
+ pass
160
+
161
+ try:
162
+ writer.compress_identical_objects(remove_identicals=True, remove_orphans=True)
163
+ except Exception: # noqa: BLE001 - best-effort de-duplication
164
+ pass
165
+
166
+ out = Path(output) if output else src.with_name(f"{src.stem}_compressed.pdf")
167
+ with out.open("wb") as fh:
168
+ writer.write(fh)
169
+ return out
170
+
171
+
172
+ def extract_text(
173
+ input_pdf: str | os.PathLike[str],
174
+ output: str | os.PathLike[str] | None = None,
175
+ *,
176
+ ocr: bool = False,
177
+ ) -> Path:
178
+ """Extract text from every page into a UTF-8 text file.
179
+
180
+ When ``ocr`` is true, render each page and use Tesseract instead of relying
181
+ on selectable PDF text. Tesseract must be installed separately.
182
+ """
183
+ src = Path(input_pdf)
184
+ text = extract_text_content(src.read_bytes(), ocr=ocr)
185
+
186
+ out = Path(output) if output else src.with_suffix(".txt")
187
+ out.parent.mkdir(parents=True, exist_ok=True)
188
+ out.write_text(text + ("\n" if text else ""), encoding="utf-8")
189
+ return out
190
+
191
+
192
+ def extract_text_content(pdf_data: bytes, *, ocr: bool = False) -> str:
193
+ """Return extracted text for PDF bytes, optionally using OCR."""
194
+ reader = PdfReader(io.BytesIO(pdf_data))
195
+ if not ocr:
196
+ pages = [(page.extract_text() or "").rstrip() for page in reader.pages]
197
+ else:
198
+ import pymupdf
199
+ import pytesseract
200
+ from PIL import Image
201
+
202
+ document = pymupdf.open(stream=pdf_data, filetype="pdf")
203
+ try:
204
+ pages = []
205
+ for page in document:
206
+ pixmap = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), alpha=False)
207
+ image = Image.frombytes(
208
+ "RGB", (pixmap.width, pixmap.height), pixmap.samples
209
+ )
210
+ pages.append(pytesseract.image_to_string(image).rstrip())
211
+ finally:
212
+ document.close()
213
+ return "\n\n".join(pages)
@@ -0,0 +1,5 @@
1
+ """Web UI package for PDFworkbench."""
2
+
3
+ from .app import app
4
+
5
+ __all__ = ["app"]
pdftoolbox/web/app.py ADDED
@@ -0,0 +1,203 @@
1
+ """FastAPI web UI for PDFworkbench (dark mode)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
10
+ from fastapi.responses import HTMLResponse, StreamingResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from fastapi.templating import Jinja2Templates
13
+ from pypdf import PdfReader, PdfWriter
14
+
15
+ from .. import __version__, operations
16
+
17
+ BASE_DIR = Path(__file__).resolve().parent
18
+
19
+ app = FastAPI(title="PDFworkbench", version=__version__)
20
+ app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
21
+ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
22
+
23
+
24
+ def serve(argv: list[str] | None = None) -> int:
25
+ """Start the web UI from the installed ``pdfworkbench-web`` command."""
26
+ import uvicorn
27
+
28
+ parser = argparse.ArgumentParser(description="Start the PDFworkbench web UI.")
29
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host.")
30
+ parser.add_argument("--port", type=int, default=8098, help="Bind port.")
31
+ parser.add_argument(
32
+ "--reload", action="store_true", help="Enable auto-reload (development)."
33
+ )
34
+ args = parser.parse_args(argv)
35
+ url = f"http://{args.host}:{args.port}"
36
+ print(f"PDFworkbench web UI running at {url} (press Ctrl+C to stop)")
37
+ uvicorn.run(
38
+ "pdftoolbox.web:app",
39
+ host=args.host,
40
+ port=args.port,
41
+ reload=args.reload,
42
+ )
43
+ return 0
44
+
45
+
46
+ @app.get("/", response_class=HTMLResponse)
47
+ async def index(request: Request) -> HTMLResponse:
48
+ return templates.TemplateResponse(request, "index.html", {"version": __version__})
49
+
50
+
51
+ def _read_upload(upload: UploadFile) -> PdfReader:
52
+ if not (upload.filename or "").lower().endswith(".pdf"):
53
+ raise HTTPException(400, f"Not a PDF file: {upload.filename}")
54
+ data = upload.file.read()
55
+ if not data:
56
+ raise HTTPException(400, f"Empty file: {upload.filename}")
57
+ try:
58
+ return PdfReader(io.BytesIO(data))
59
+ except Exception as exc: # noqa: BLE001 - surface a clean error to the client
60
+ raise HTTPException(400, f"Could not read {upload.filename}: {exc}") from exc
61
+
62
+
63
+ @app.post("/api/merge")
64
+ async def api_merge(files: list[UploadFile] = File(...)):
65
+ if len(files) < 2:
66
+ raise HTTPException(400, "Please upload at least two PDF files to merge.")
67
+
68
+ writer = PdfWriter()
69
+ for upload in files:
70
+ reader = _read_upload(upload)
71
+ for page in reader.pages:
72
+ writer.add_page(page)
73
+
74
+ buffer = io.BytesIO()
75
+ writer.write(buffer)
76
+ buffer.seek(0)
77
+ return StreamingResponse(
78
+ buffer,
79
+ media_type="application/pdf",
80
+ headers={"Content-Disposition": "attachment; filename=merged.pdf"},
81
+ )
82
+
83
+
84
+ @app.post("/api/split")
85
+ async def api_split(file: UploadFile = File(...)):
86
+ import zipfile
87
+
88
+ reader = _read_upload(file)
89
+ stem = Path(file.filename or "document").stem
90
+
91
+ zip_buffer = io.BytesIO()
92
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
93
+ width = len(str(len(reader.pages)))
94
+ for index, page in enumerate(reader.pages, start=1):
95
+ writer = PdfWriter()
96
+ writer.add_page(page)
97
+ page_buffer = io.BytesIO()
98
+ writer.write(page_buffer)
99
+ zf.writestr(f"{stem}_{index:0{width}d}.pdf", page_buffer.getvalue())
100
+
101
+ zip_buffer.seek(0)
102
+ return StreamingResponse(
103
+ zip_buffer,
104
+ media_type="application/zip",
105
+ headers={"Content-Disposition": f"attachment; filename={stem}_pages.zip"},
106
+ )
107
+
108
+
109
+ @app.post("/api/rotate")
110
+ async def api_rotate(file: UploadFile = File(...), degrees: int = Form(90)):
111
+ if degrees % 90 != 0:
112
+ raise HTTPException(400, "Rotation must be a multiple of 90 degrees.")
113
+
114
+ reader = _read_upload(file)
115
+ writer = PdfWriter()
116
+ for page in reader.pages:
117
+ page.rotate(degrees)
118
+ writer.add_page(page)
119
+
120
+ buffer = io.BytesIO()
121
+ writer.write(buffer)
122
+ buffer.seek(0)
123
+ stem = Path(file.filename or "document").stem
124
+ return StreamingResponse(
125
+ buffer,
126
+ media_type="application/pdf",
127
+ headers={"Content-Disposition": f"attachment; filename={stem}_rotated.pdf"},
128
+ )
129
+
130
+
131
+ @app.post("/api/compress")
132
+ async def api_compress(
133
+ file: UploadFile = File(...),
134
+ image_quality: int = Form(60),
135
+ image_scale: float = Form(1.0),
136
+ compress_streams: bool = Form(True),
137
+ ):
138
+ if not 1 <= image_quality <= 100:
139
+ raise HTTPException(400, "Image quality must be between 1 and 100.")
140
+ if not 0 < image_scale <= 1:
141
+ raise HTTPException(400, "Image scale must be between 0 and 1.")
142
+
143
+ reader = _read_upload(file)
144
+ writer = PdfWriter()
145
+ writer.append(reader)
146
+
147
+ for page in writer.pages:
148
+ for img in page.images:
149
+ pil_image = img.image
150
+ if pil_image is None:
151
+ continue
152
+ if image_scale < 1.0:
153
+ new_size = (
154
+ max(1, int(pil_image.width * image_scale)),
155
+ max(1, int(pil_image.height * image_scale)),
156
+ )
157
+ pil_image = pil_image.resize(new_size)
158
+ img.replace(pil_image, quality=image_quality)
159
+ if compress_streams:
160
+ try:
161
+ page.compress_content_streams()
162
+ except Exception: # noqa: BLE001 - some streams can't be re-deflated
163
+ pass
164
+
165
+ try:
166
+ writer.compress_identical_objects(remove_identicals=True, remove_orphans=True)
167
+ except Exception: # noqa: BLE001 - best-effort de-duplication
168
+ pass
169
+
170
+ buffer = io.BytesIO()
171
+ writer.write(buffer)
172
+ buffer.seek(0)
173
+ stem = Path(file.filename or "document").stem
174
+ return StreamingResponse(
175
+ buffer,
176
+ media_type="application/pdf",
177
+ headers={"Content-Disposition": f"attachment; filename={stem}_compressed.pdf"},
178
+ )
179
+
180
+
181
+ @app.post("/api/extract")
182
+ async def api_extract(
183
+ file: UploadFile = File(...),
184
+ ocr: bool = Form(False),
185
+ ):
186
+ if not (file.filename or "").lower().endswith(".pdf"):
187
+ raise HTTPException(400, f"Not a PDF file: {file.filename}")
188
+ data = await file.read()
189
+ if not data:
190
+ raise HTTPException(400, f"Empty file: {file.filename}")
191
+ try:
192
+ text = operations.extract_text_content(data, ocr=ocr)
193
+ except Exception as exc: # noqa: BLE001 - surface a clean error to the client
194
+ raise HTTPException(
195
+ 400, f"Could not extract text from {file.filename}: {exc}"
196
+ ) from exc
197
+ buffer = io.BytesIO((text + ("\n" if text else "")).encode("utf-8"))
198
+ stem = Path(file.filename or "document").stem
199
+ return StreamingResponse(
200
+ buffer,
201
+ media_type="text/plain; charset=utf-8",
202
+ headers={"Content-Disposition": f"attachment; filename={stem}.txt"},
203
+ )