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.
- pdfworkbench-1.0.0/PKG-INFO +107 -0
- pdfworkbench-1.0.0/README.md +92 -0
- pdfworkbench-1.0.0/pdftoolbox/__init__.py +3 -0
- pdfworkbench-1.0.0/pdftoolbox/cli.py +137 -0
- pdfworkbench-1.0.0/pdftoolbox/operations.py +213 -0
- pdfworkbench-1.0.0/pdftoolbox/web/__init__.py +5 -0
- pdfworkbench-1.0.0/pdftoolbox/web/app.py +203 -0
- pdfworkbench-1.0.0/pdftoolbox/web/static/script.js +235 -0
- pdfworkbench-1.0.0/pdftoolbox/web/static/style.css +308 -0
- pdfworkbench-1.0.0/pdftoolbox/web/templates/index.html +186 -0
- pdfworkbench-1.0.0/pdfworkbench.egg-info/PKG-INFO +107 -0
- pdfworkbench-1.0.0/pdfworkbench.egg-info/SOURCES.txt +16 -0
- pdfworkbench-1.0.0/pdfworkbench.egg-info/dependency_links.txt +1 -0
- pdfworkbench-1.0.0/pdfworkbench.egg-info/entry_points.txt +3 -0
- pdfworkbench-1.0.0/pdfworkbench.egg-info/requires.txt +8 -0
- pdfworkbench-1.0.0/pdfworkbench.egg-info/top_level.txt +1 -0
- pdfworkbench-1.0.0/pyproject.toml +33 -0
- pdfworkbench-1.0.0/setup.cfg +4 -0
|
@@ -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,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)
|