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 +3 -0
- pdftoolbox/cli.py +137 -0
- pdftoolbox/operations.py +213 -0
- pdftoolbox/web/__init__.py +5 -0
- pdftoolbox/web/app.py +203 -0
- pdftoolbox/web/static/script.js +235 -0
- pdftoolbox/web/static/style.css +308 -0
- pdftoolbox/web/templates/index.html +186 -0
- pdfworkbench-1.0.0.dist-info/METADATA +107 -0
- pdfworkbench-1.0.0.dist-info/RECORD +13 -0
- pdfworkbench-1.0.0.dist-info/WHEEL +5 -0
- pdfworkbench-1.0.0.dist-info/entry_points.txt +3 -0
- pdfworkbench-1.0.0.dist-info/top_level.txt +1 -0
pdftoolbox/__init__.py
ADDED
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())
|
pdftoolbox/operations.py
ADDED
|
@@ -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)
|
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
|
+
)
|