pdfworkbench 1.0.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,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdfworkbench
3
+ Version: 1.0.0
4
+ Summary: A toolbox for common PDF operations with a FastAPI web UI and a CLI.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi>=0.110
8
+ Requires-Dist: uvicorn[standard]>=0.29
9
+ Requires-Dist: pypdf>=4.2
10
+ Requires-Dist: pillow>=10.0
11
+ Requires-Dist: python-multipart>=0.0.9
12
+ Requires-Dist: jinja2>=3.1
13
+ Requires-Dist: pytesseract>=0.3.13
14
+ Requires-Dist: pymupdf>=1.24
15
+
16
+ # PDFworkbench
17
+
18
+ A small toolbox for common PDF tasks with **two** interfaces:
19
+
20
+ 1. **Web UI** — a dark-mode FastAPI app (merge, split, rotate, compress, extract text).
21
+ 2. **CLI** — e.g. `pdfworkbench -merge --dir <directory_name>` merges every PDF in a folder.
22
+
23
+ ## Requirements
24
+
25
+ - Python 3.10+
26
+
27
+ ## Install
28
+
29
+ ```powershell
30
+ python -m venv .venv
31
+ .\.venv\Scripts\Activate.ps1
32
+ pip install pdfworkbench
33
+ ```
34
+
35
+ For local development from a checkout, use an editable install instead:
36
+
37
+ ```powershell
38
+ pip install -e .
39
+ ```
40
+
41
+ ## Run the web UI
42
+
43
+ ```powershell
44
+ pdfworkbench-web
45
+ ```
46
+
47
+ Then open http://127.0.0.1:8098 in your browser. Optional flags:
48
+
49
+ ```powershell
50
+ pdfworkbench-web --host 0.0.0.0 --port 8098 --reload
51
+ ```
52
+
53
+ The UI is also available during development with `python main.py`.
54
+
55
+ ## Command line
56
+
57
+ The CLI is available through the installed `pdfworkbench` command:
58
+
59
+ ```powershell
60
+ # Merge every PDF in a directory into one file (writes <dir>/merged.pdf)
61
+ pdfworkbench -merge --dir .\invoices
62
+
63
+ # Same, but recurse into sub-folders and choose the output name
64
+ pdfworkbench -merge --dir .\invoices --recursive --output all.pdf
65
+
66
+ # Merge specific files in order
67
+ pdfworkbench -merge --input a.pdf b.pdf c.pdf --output combined.pdf
68
+
69
+ # Split a PDF into one file per page
70
+ pdfworkbench -split --input big.pdf
71
+
72
+ # Rotate all pages
73
+ pdfworkbench -rotate --input scan.pdf --degrees 90
74
+
75
+ # Compress a PDF
76
+ pdfworkbench -compress --input scan.pdf
77
+
78
+ # Extract text from every page into scan.txt
79
+ pdfworkbench -extract --input scan.pdf
80
+
81
+ # Extract text from scanned pages with OCR
82
+ pdfworkbench -extract --input scan.pdf --ocr
83
+ ```
84
+
85
+ For local development from a checkout, use `python main.py` with the same
86
+ operation flags.
87
+
88
+ OCR requires the Tesseract executable to be installed and available on your
89
+ PATH. The Python package installs the OCR integration and PDF renderer, but not
90
+ the Tesseract executable itself.
91
+
92
+ ## Project layout
93
+
94
+ ```
95
+ pdftoolbox/
96
+ ├── main.py # entry point: web UI or CLI dispatch
97
+ ├── pyproject.toml # packaging + `pdfworkbench` console scripts
98
+ └── pdftoolbox/
99
+ ├── operations.py # core PDF logic (shared by CLI + web)
100
+ ├── cli.py # command line interface
101
+ └── web/
102
+ ├── app.py # FastAPI application
103
+ ├── templates/index.html
104
+ └── static/ # style.css, script.js
105
+ ```
106
+
107
+ All processing happens locally — files are never uploaded to a third-party server.
@@ -0,0 +1,92 @@
1
+ # PDFworkbench
2
+
3
+ A small toolbox for common PDF tasks with **two** interfaces:
4
+
5
+ 1. **Web UI** — a dark-mode FastAPI app (merge, split, rotate, compress, extract text).
6
+ 2. **CLI** — e.g. `pdfworkbench -merge --dir <directory_name>` merges every PDF in a folder.
7
+
8
+ ## Requirements
9
+
10
+ - Python 3.10+
11
+
12
+ ## Install
13
+
14
+ ```powershell
15
+ python -m venv .venv
16
+ .\.venv\Scripts\Activate.ps1
17
+ pip install pdfworkbench
18
+ ```
19
+
20
+ For local development from a checkout, use an editable install instead:
21
+
22
+ ```powershell
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Run the web UI
27
+
28
+ ```powershell
29
+ pdfworkbench-web
30
+ ```
31
+
32
+ Then open http://127.0.0.1:8098 in your browser. Optional flags:
33
+
34
+ ```powershell
35
+ pdfworkbench-web --host 0.0.0.0 --port 8098 --reload
36
+ ```
37
+
38
+ The UI is also available during development with `python main.py`.
39
+
40
+ ## Command line
41
+
42
+ The CLI is available through the installed `pdfworkbench` command:
43
+
44
+ ```powershell
45
+ # Merge every PDF in a directory into one file (writes <dir>/merged.pdf)
46
+ pdfworkbench -merge --dir .\invoices
47
+
48
+ # Same, but recurse into sub-folders and choose the output name
49
+ pdfworkbench -merge --dir .\invoices --recursive --output all.pdf
50
+
51
+ # Merge specific files in order
52
+ pdfworkbench -merge --input a.pdf b.pdf c.pdf --output combined.pdf
53
+
54
+ # Split a PDF into one file per page
55
+ pdfworkbench -split --input big.pdf
56
+
57
+ # Rotate all pages
58
+ pdfworkbench -rotate --input scan.pdf --degrees 90
59
+
60
+ # Compress a PDF
61
+ pdfworkbench -compress --input scan.pdf
62
+
63
+ # Extract text from every page into scan.txt
64
+ pdfworkbench -extract --input scan.pdf
65
+
66
+ # Extract text from scanned pages with OCR
67
+ pdfworkbench -extract --input scan.pdf --ocr
68
+ ```
69
+
70
+ For local development from a checkout, use `python main.py` with the same
71
+ operation flags.
72
+
73
+ OCR requires the Tesseract executable to be installed and available on your
74
+ PATH. The Python package installs the OCR integration and PDF renderer, but not
75
+ the Tesseract executable itself.
76
+
77
+ ## Project layout
78
+
79
+ ```
80
+ pdftoolbox/
81
+ ├── main.py # entry point: web UI or CLI dispatch
82
+ ├── pyproject.toml # packaging + `pdfworkbench` console scripts
83
+ └── pdftoolbox/
84
+ ├── operations.py # core PDF logic (shared by CLI + web)
85
+ ├── cli.py # command line interface
86
+ └── web/
87
+ ├── app.py # FastAPI application
88
+ ├── templates/index.html
89
+ └── static/ # style.css, script.js
90
+ ```
91
+
92
+ All processing happens locally — files are never uploaded to a third-party server.
@@ -0,0 +1,3 @@
1
+ """PDFworkbench - a toolbox for common PDF operations."""
2
+
3
+ __version__ = "1.0.0"
@@ -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"]