pdf-compressor 0.4.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.
- pdf_compressor/__init__.py +10 -0
- pdf_compressor/ilovepdf.py +348 -0
- pdf_compressor/main.py +331 -0
- pdf_compressor/utils.py +210 -0
- pdf_compressor-0.4.0.dist-info/METADATA +98 -0
- pdf_compressor-0.4.0.dist-info/RECORD +9 -0
- pdf_compressor-0.4.0.dist-info/WHEEL +6 -0
- pdf_compressor-0.4.0.dist-info/entry_points.txt +2 -0
- pdf_compressor-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from pdf_compressor.ilovepdf import Compress, ILovePDF, Task
|
|
4
|
+
from pdf_compressor.main import DEFAULT_SUFFIX, compress, main
|
|
5
|
+
from pdf_compressor.utils import si_fmt
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version(__name__)
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
pass # package not installed
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Code in this file was adapted from Andrea Bruschi's pylovepdf.
|
|
2
|
+
|
|
3
|
+
https://github.com/AndyCyberSec/pylovepdf
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from typing import Any, BinaryIO, Literal, TypedDict
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
from requests import Response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProcessResponse(TypedDict):
|
|
18
|
+
"""Type of ILovePDF.process() return value."""
|
|
19
|
+
|
|
20
|
+
timer: str
|
|
21
|
+
status: str
|
|
22
|
+
download_filename: str
|
|
23
|
+
filesize: int
|
|
24
|
+
output_filesize: int
|
|
25
|
+
output_filenumber: int
|
|
26
|
+
output_extensions: list[str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ILovePDF:
|
|
30
|
+
"""Communicates with the iLovePDF API."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, public_key: str, *, debug: bool = False) -> None:
|
|
33
|
+
"""Creates a new iLovePDF object to interact with the API.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
public_key (str): iLovePDF API key. Get yours by signing up at
|
|
37
|
+
https://developer.ilovepdf.com/signup.
|
|
38
|
+
debug (bool, optional): Whether to perform real API requests (consumes
|
|
39
|
+
quota) or just report what would happen. Defaults to False.
|
|
40
|
+
"""
|
|
41
|
+
self.public_key = public_key
|
|
42
|
+
|
|
43
|
+
self.api_version = "v1"
|
|
44
|
+
self.start_server = "api.ilovepdf.com"
|
|
45
|
+
self.working_server = ""
|
|
46
|
+
# Any resource can be called with a debug option. When true, iLovePDF won't
|
|
47
|
+
# process the request but will output the parameters received by the server.
|
|
48
|
+
self.debug = debug # https://developer.ilovepdf.com/docs/api-reference#testing
|
|
49
|
+
|
|
50
|
+
# header will contain authorization token to be sent with every task
|
|
51
|
+
self.headers: dict[str, str] | None = None
|
|
52
|
+
|
|
53
|
+
self.auth()
|
|
54
|
+
|
|
55
|
+
def auth(self) -> None:
|
|
56
|
+
"""Get iLovePDF API session token."""
|
|
57
|
+
payload = {"public_key": self.public_key}
|
|
58
|
+
|
|
59
|
+
response = self._send_request("post", endpoint="auth", payload=payload)
|
|
60
|
+
|
|
61
|
+
self.headers = {"Authorization": f"Bearer {response.json()['token']}"}
|
|
62
|
+
|
|
63
|
+
def get_quota(self) -> int:
|
|
64
|
+
"""Response has only one key: {'remaining_credits': int}.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
int: Number of remaining files that can be processed by the API in the
|
|
68
|
+
current billing cycle.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
response = json.loads(self._send_request("get", "info").text)
|
|
72
|
+
|
|
73
|
+
return response["remaining_credits"]
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
exc.add_note(f"{locals()=}")
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
def _send_request(
|
|
79
|
+
self,
|
|
80
|
+
method: Literal["get", "post", "delete"],
|
|
81
|
+
endpoint: str,
|
|
82
|
+
*,
|
|
83
|
+
payload: dict[str, Any] | None = None,
|
|
84
|
+
files: dict[str, BinaryIO] | None = None,
|
|
85
|
+
stream: bool = False,
|
|
86
|
+
) -> Response:
|
|
87
|
+
# continue to use old server if task was already assigned one, else connect to
|
|
88
|
+
# new server
|
|
89
|
+
server = self.working_server or self.start_server
|
|
90
|
+
payload = payload or {}
|
|
91
|
+
|
|
92
|
+
url = f"https://{server}/{self.api_version}/{endpoint}"
|
|
93
|
+
|
|
94
|
+
if self.debug:
|
|
95
|
+
payload["debug"] = True
|
|
96
|
+
|
|
97
|
+
response = getattr(requests, method)(
|
|
98
|
+
url, data=payload, headers=self.headers, files=files, stream=stream
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if not response.ok:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Error: {response.url} returned status code {response.status_code}, "
|
|
104
|
+
f"reason: '{response.reason}'. Full response text is: '{response.text}'"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return response
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Task(ILovePDF):
|
|
111
|
+
"""Class for interacting with the iLovePDF request workflow.
|
|
112
|
+
|
|
113
|
+
https://developer.ilovepdf.com/docs/api-reference#request-workflow
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
public_key: str,
|
|
119
|
+
tool: str,
|
|
120
|
+
*,
|
|
121
|
+
verbose: bool = False,
|
|
122
|
+
password: str = "",
|
|
123
|
+
**kwargs: Any,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Creates a new task object to interact with the API.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
public_key (str): iLovePDF API key.
|
|
129
|
+
tool (str): The desired API tool you wish to access. Possible values: merge,
|
|
130
|
+
split, compress, pdfjpg, imagepdf, unlock, pagenumber, watermark, pdfa,
|
|
131
|
+
officepdf, repair, rotate, protect, validatepdfa, htmlpdf, extract.
|
|
132
|
+
pdf-compressor only supports 'compress'. Might change in the future.
|
|
133
|
+
https://developer.ilovepdf.com/docs/api-reference#process.
|
|
134
|
+
verbose (bool, optional): Whether to print progress messages while uploading
|
|
135
|
+
and processing files. Defaults to False.
|
|
136
|
+
password (str, optional): Password to open PDFs in case they have one.
|
|
137
|
+
Defaults to "".
|
|
138
|
+
**kwargs: Additional keyword arguments to pass to ILovePDF.__init__().
|
|
139
|
+
"""
|
|
140
|
+
super().__init__(public_key, **kwargs)
|
|
141
|
+
|
|
142
|
+
self.files: dict[str, str] = {}
|
|
143
|
+
self._task_id = ""
|
|
144
|
+
self._process_response: ProcessResponse | None = None
|
|
145
|
+
|
|
146
|
+
self.verbose = verbose
|
|
147
|
+
self.tool = tool
|
|
148
|
+
self.password = password
|
|
149
|
+
|
|
150
|
+
# API options https://developer.ilovepdf.com/docs/api-reference#process
|
|
151
|
+
# placeholders like {app}, {n}, {filename} in output/packaged_filename will be
|
|
152
|
+
# inserted by iLovePDF.
|
|
153
|
+
# See https://developer.ilovepdf.com/docs/api-reference#output_filename
|
|
154
|
+
self.process_params = {
|
|
155
|
+
"tool": tool,
|
|
156
|
+
"ignore_password": True,
|
|
157
|
+
# important to keep {n} in front as sort order is used to match existing
|
|
158
|
+
# to downloaded files
|
|
159
|
+
"output_filename": "{n}-{filename}-{app}",
|
|
160
|
+
"packaged_filename": "{app}ed-PDFs",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
self.start()
|
|
164
|
+
|
|
165
|
+
def start(self) -> None:
|
|
166
|
+
"""Initiate contact with iLovePDF API to get assigned a working server that will
|
|
167
|
+
handle ensuing requests.
|
|
168
|
+
"""
|
|
169
|
+
response = self._send_request("get", f"start/{self.tool}").json()
|
|
170
|
+
|
|
171
|
+
if response:
|
|
172
|
+
self.working_server = response["server"]
|
|
173
|
+
|
|
174
|
+
self._task_id = response["task"]
|
|
175
|
+
|
|
176
|
+
else:
|
|
177
|
+
print(
|
|
178
|
+
"Warning: Starting this task returned empty JSON response. "
|
|
179
|
+
"Was likely already started."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def add_file(self, file_path: str) -> None:
|
|
183
|
+
"""Add a file to the task.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
file_path (str): Path of file to be added.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
FileNotFoundError: If file does not exist.
|
|
190
|
+
"""
|
|
191
|
+
if not os.path.isfile(file_path):
|
|
192
|
+
raise FileNotFoundError(f"'{file_path}' does not exist")
|
|
193
|
+
|
|
194
|
+
if file_path in self.files:
|
|
195
|
+
print(f"Warning: File '{file_path}' was already added to this task.")
|
|
196
|
+
|
|
197
|
+
self.files[file_path] = ""
|
|
198
|
+
|
|
199
|
+
def upload(self) -> dict[str, str]:
|
|
200
|
+
"""Second step of the task is where the PDFs files to be processed are uploaded
|
|
201
|
+
to iLovePDF servers.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
dict[str, str]: Map from local filenames to corresponding filenames on the
|
|
205
|
+
server.
|
|
206
|
+
"""
|
|
207
|
+
payload = {"task": self._task_id}
|
|
208
|
+
|
|
209
|
+
for filename in self.files:
|
|
210
|
+
with open(filename, "rb") as file:
|
|
211
|
+
response = self._send_request(
|
|
212
|
+
"post", "upload", payload=payload, files={"file": file}
|
|
213
|
+
).json()
|
|
214
|
+
|
|
215
|
+
# server_filename is the only key in the JSON response
|
|
216
|
+
self.files[filename] = response["server_filename"]
|
|
217
|
+
|
|
218
|
+
return self.files
|
|
219
|
+
|
|
220
|
+
def process(self) -> ProcessResponse:
|
|
221
|
+
"""Uploads and then processes files added to this Task. Files will be processed
|
|
222
|
+
in the same order as iterating over self.files.items().
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
ProcessResponse: The post-processing JSON response.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If the task ID is invalid.
|
|
229
|
+
"""
|
|
230
|
+
if self.verbose:
|
|
231
|
+
print("Uploading file(s)...")
|
|
232
|
+
|
|
233
|
+
self.upload()
|
|
234
|
+
|
|
235
|
+
payload = self.process_params.copy()
|
|
236
|
+
payload["task"] = self._task_id
|
|
237
|
+
|
|
238
|
+
for idx, (filename, server_filename) in enumerate(self.files.items()):
|
|
239
|
+
payload[f"files[{idx}][filename]"] = filename
|
|
240
|
+
payload[f"files[{idx}][server_filename]"] = server_filename
|
|
241
|
+
payload[f"files[{idx}][password]"] = self.password
|
|
242
|
+
|
|
243
|
+
response: ProcessResponse = self._send_request(
|
|
244
|
+
"post", "process", payload=payload
|
|
245
|
+
).json()
|
|
246
|
+
|
|
247
|
+
self._process_response = response
|
|
248
|
+
n_files = response["output_filenumber"]
|
|
249
|
+
|
|
250
|
+
if len(self.files) != response["output_filenumber"]:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"Unexpected file count mismatch: task received {len(self.files)} files"
|
|
253
|
+
f" for processing, but only {n_files} were downloaded from server."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if self.verbose:
|
|
257
|
+
print(f"File(s) uploaded and processed!\n{response = }")
|
|
258
|
+
|
|
259
|
+
return response
|
|
260
|
+
|
|
261
|
+
def download(self, save_to_dir: str | None = None) -> str:
|
|
262
|
+
"""Download this task's output file(s) for the given task. Should not be called
|
|
263
|
+
until after task.process(). In case of a single output file, it is saved to disk
|
|
264
|
+
as a PDF. Multiple files are saved in a compressed ZIP folder.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
ValueError: If task.download() is called in absence of downloadable files,
|
|
268
|
+
usually because task.process() wasn't called yet.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
str: Path to the newly downloaded file.
|
|
272
|
+
"""
|
|
273
|
+
if not self._process_response:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
"You called task.download() but there are no files to download"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
endpoint = f"download/{self._task_id}"
|
|
279
|
+
|
|
280
|
+
response = self._send_request("get", endpoint, stream=True)
|
|
281
|
+
|
|
282
|
+
if not save_to_dir: # save_to_dir is None or ''
|
|
283
|
+
save_to_dir = tempfile.mkdtemp()
|
|
284
|
+
|
|
285
|
+
file_path = os.path.join(
|
|
286
|
+
save_to_dir, self._process_response["download_filename"]
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# create parent dir (we use download_filename instead of save_to_dir because it
|
|
290
|
+
# may contain subdirs)
|
|
291
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
292
|
+
|
|
293
|
+
# response.content is PDF file or ZIP archive, either way, we save as binary
|
|
294
|
+
with open(file_path, mode="wb") as file:
|
|
295
|
+
file.write(response.content)
|
|
296
|
+
|
|
297
|
+
return file_path
|
|
298
|
+
|
|
299
|
+
def delete_current_task(self) -> None:
|
|
300
|
+
"""Delete the current task from iLovePDF servers.
|
|
301
|
+
|
|
302
|
+
https://developer.ilovepdf.com/docs/api-reference#task
|
|
303
|
+
"""
|
|
304
|
+
if not self._task_id:
|
|
305
|
+
print("Warning: You're trying to delete a task that was never started")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
self._send_request("delete", f"task/{self._task_id}")
|
|
309
|
+
self._task_id = ""
|
|
310
|
+
self._process_response = None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class Compress(Task):
|
|
314
|
+
"""Use the iLovePDF compression tool.
|
|
315
|
+
|
|
316
|
+
Example:
|
|
317
|
+
from pdf_compressor import Compress
|
|
318
|
+
|
|
319
|
+
task = Compress("public_key")
|
|
320
|
+
task.add_file("pdf_file")
|
|
321
|
+
task.process()
|
|
322
|
+
task.download("output/dir")
|
|
323
|
+
task.delete_current_task()
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def __init__(
|
|
327
|
+
self, public_key: str, compression_level: str = "recommended", **kwargs: Any
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Subclass of Task for using the iLovePDF compression tool.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
public_key (str): iLovePDF public API key. Get yours by signing up at
|
|
333
|
+
https://developer.ilovepdf.com/signup.
|
|
334
|
+
compression_level (str, optional): How hard to squeeze the file size.
|
|
335
|
+
'extreme' noticeably degrades image quality. Defaults to 'recommended'.
|
|
336
|
+
**kwargs: Additional keyword arguments to pass to Task.__init__().
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ValueError: If the compression level is invalid.
|
|
340
|
+
"""
|
|
341
|
+
super().__init__(public_key, tool="compress", **kwargs)
|
|
342
|
+
|
|
343
|
+
if compression_level not in (valid_levels := ("low", "recommended", "extreme")):
|
|
344
|
+
raise ValueError(
|
|
345
|
+
f"Invalid {compression_level=}, must be one of {valid_levels}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
self.process_params["compression_level"] = compression_level
|
pdf_compressor/main.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from argparse import ArgumentParser
|
|
6
|
+
from glob import glob
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from pdf_compressor.ilovepdf import Compress, ILovePDF
|
|
11
|
+
from pdf_compressor.utils import ROOT, del_or_keep_compressed, load_dotenv
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
DEFAULT_SUFFIX = "-compressed"
|
|
17
|
+
API_KEY_KEY = "ILOVEPDF_PUBLIC_KEY"
|
|
18
|
+
MISSING_API_KEY_ERR = KeyError(
|
|
19
|
+
"pdf-compressor needs an iLovePDF public key to access its API. Set one "
|
|
20
|
+
"with pdf-compressor --set-api-key project_public_7af905e... or as environment "
|
|
21
|
+
f"variable {API_KEY_KEY}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
26
|
+
"""Compress PDFs using iLovePDF's API.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
argv (Sequence[str] | None): Command line arguments.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
int: 0 if successful, else error code.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If the API key is invalid.
|
|
36
|
+
"""
|
|
37
|
+
parser = ArgumentParser(
|
|
38
|
+
description="Batch compress PDFs on the command line. Powered by iLovePDF.com.",
|
|
39
|
+
allow_abbrev=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
parser.add_argument("filenames", nargs="*", help="List of PDF files to compress.")
|
|
43
|
+
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--set-api-key",
|
|
46
|
+
help="Set the public key needed to authenticate with the iLovePDF API. Exits "
|
|
47
|
+
"immediately afterwards ignoring all other flags.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--password",
|
|
52
|
+
type=str,
|
|
53
|
+
default="",
|
|
54
|
+
help="Password for protected PDF files. All files will use the same password. "
|
|
55
|
+
"Protected PDFs with different passwords must be compressed one by one.",
|
|
56
|
+
)
|
|
57
|
+
group = parser.add_mutually_exclusive_group()
|
|
58
|
+
|
|
59
|
+
group.add_argument(
|
|
60
|
+
"-i",
|
|
61
|
+
"--inplace",
|
|
62
|
+
action="store_true",
|
|
63
|
+
help="Whether to compress PDFs in place. Defaults to False.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"-o",
|
|
67
|
+
"--outdir",
|
|
68
|
+
type=str,
|
|
69
|
+
default="",
|
|
70
|
+
help="Output directory for compressed PDFs. Defaults to the current working "
|
|
71
|
+
"directory.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
group.add_argument(
|
|
75
|
+
"-s",
|
|
76
|
+
"--suffix",
|
|
77
|
+
default=DEFAULT_SUFFIX,
|
|
78
|
+
help="String to append to the filename of compressed PDFs. Mutually exclusive "
|
|
79
|
+
"with --inplace flag.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--report-quota",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Report how much of the monthly quota for the current API key has been "
|
|
86
|
+
"used up.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--compression-level",
|
|
91
|
+
"--cl",
|
|
92
|
+
choices=("low", "recommended", "extreme"),
|
|
93
|
+
default="recommended",
|
|
94
|
+
help="How hard to squeeze the file size. 'extreme' noticeably degrades image "
|
|
95
|
+
"quality. Defaults to 'recommended'.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--min-size-reduction",
|
|
100
|
+
"--min-red",
|
|
101
|
+
type=int,
|
|
102
|
+
choices=range(101),
|
|
103
|
+
metavar="[0-100]", # prevents long list in argparse help message
|
|
104
|
+
help="How much compressed files need to be smaller than originals (in percent) "
|
|
105
|
+
"for them to be kept. Defaults to 10 when also passing -i/--inplace, else 0."
|
|
106
|
+
"For example, when compressing files in-place and only achieving 5%% file size "
|
|
107
|
+
"reduction, the compressed file will be discarded.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--debug",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="When true, iLovePDF won't process the request but will output the "
|
|
114
|
+
"parameters received by the server. Defaults to False.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--verbose",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="When true, progress will be reported while tasks are running. Also prints"
|
|
121
|
+
" full file paths to compressed files instead of file name only. Defaults "
|
|
122
|
+
"to False.",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--on-no-files",
|
|
127
|
+
choices=("error", "ignore"),
|
|
128
|
+
default="ignore",
|
|
129
|
+
help="What to do when no input PDFs received. One of 'ignore' or 'error', "
|
|
130
|
+
"former exits 0, latter throws ValueError. Can be useful when using "
|
|
131
|
+
"pdf-compressor in shell scripts. Defaults to 'ignore'.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--on-bad-files",
|
|
136
|
+
choices=("error", "warn", "ignore"),
|
|
137
|
+
default="error",
|
|
138
|
+
help="How to behave when receiving input files that don't appear to be PDFs. "
|
|
139
|
+
"One of 'error', 'warn', 'ignore'. Error will be TypeError. "
|
|
140
|
+
"Defaults to 'error'.",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--write-stats-path",
|
|
145
|
+
type=str,
|
|
146
|
+
default="",
|
|
147
|
+
help="File path to write a CSV, Excel or other pandas supported file format "
|
|
148
|
+
"with original vs compressed file sizes and actions taken on each input file",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
pkg_version = version(pkg_name := "pdf-compressor")
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"-v", "--version", action="version", version=f"{pkg_name} v{pkg_version}"
|
|
154
|
+
)
|
|
155
|
+
args, _unknown_args = parser.parse_known_args(argv)
|
|
156
|
+
|
|
157
|
+
if new_key := args.set_api_key:
|
|
158
|
+
if not new_key.startswith("project_public_"):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"invalid API key, must start with 'project_public_', got {new_key=}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
with open(f"{ROOT}/.env", mode="w+", encoding="utf8") as file:
|
|
164
|
+
file.write(f"ILOVEPDF_PUBLIC_KEY={new_key}\n")
|
|
165
|
+
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
load_dotenv()
|
|
169
|
+
if not (api_key := os.environ[API_KEY_KEY]):
|
|
170
|
+
raise MISSING_API_KEY_ERR
|
|
171
|
+
|
|
172
|
+
if args.report_quota:
|
|
173
|
+
remaining_credits = ILovePDF(api_key).get_quota()
|
|
174
|
+
|
|
175
|
+
print(f"Remaining files in this billing cycle: {remaining_credits:,}")
|
|
176
|
+
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
return compress(**vars(args)) # ty: ignore[missing-argument]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def compress(
|
|
183
|
+
filenames: Sequence[str],
|
|
184
|
+
*,
|
|
185
|
+
inplace: bool = False,
|
|
186
|
+
outdir: str = "",
|
|
187
|
+
suffix: str = DEFAULT_SUFFIX,
|
|
188
|
+
compression_level: str = "recommended",
|
|
189
|
+
min_size_reduction: int | None = None,
|
|
190
|
+
debug: bool = False,
|
|
191
|
+
verbose: bool = False,
|
|
192
|
+
on_no_files: str = "ignore",
|
|
193
|
+
on_bad_files: str = "error",
|
|
194
|
+
write_stats_path: str = "",
|
|
195
|
+
password: str = "",
|
|
196
|
+
**kwargs: Any, # noqa: ARG001
|
|
197
|
+
) -> int:
|
|
198
|
+
"""Compress PDFs using iLovePDF's API.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
filenames (list[str]): List of PDF files to compress.
|
|
202
|
+
inplace (bool): Whether to compress PDFs in place.
|
|
203
|
+
outdir (str): Output directory for compressed PDFs. Defaults to the current
|
|
204
|
+
working directory.
|
|
205
|
+
suffix (str): String to append to the filename of compressed PDFs.
|
|
206
|
+
compression_level (str): How hard to squeeze the file size.
|
|
207
|
+
min_size_reduction (int): How much compressed files need to be smaller than
|
|
208
|
+
originals (in percent) for them to be kept.
|
|
209
|
+
debug (bool): When true, iLovePDF won't process the request but will output the
|
|
210
|
+
parameters received by the server.
|
|
211
|
+
verbose (bool): When true, progress will be reported while tasks are running.
|
|
212
|
+
on_no_files (str): What to do when no input PDFs received.
|
|
213
|
+
on_bad_files (str): How to behave when receiving input files that don't appear
|
|
214
|
+
to be PDFs.
|
|
215
|
+
write_stats_path (str): File path to write a CSV, Excel or other pandas
|
|
216
|
+
supported file format with original vs compressed file sizes and actions
|
|
217
|
+
taken on each input file
|
|
218
|
+
password (str): Password to open PDFs in case they have one. Defaults to "".
|
|
219
|
+
TODO There's currently no way of passing different passwords for different
|
|
220
|
+
files. PDFs with different passwords must be compressed one by one.
|
|
221
|
+
**kwargs: Additional keywords are ignored.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
int: 0 if successful, else error code.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ImportError: If pandas is not installed.
|
|
228
|
+
ValueError: If the API key is invalid.
|
|
229
|
+
"""
|
|
230
|
+
if min_size_reduction is None:
|
|
231
|
+
min_size_reduction = 10 if inplace else 0
|
|
232
|
+
|
|
233
|
+
load_dotenv()
|
|
234
|
+
if not (api_key := os.environ[API_KEY_KEY]):
|
|
235
|
+
raise MISSING_API_KEY_ERR
|
|
236
|
+
|
|
237
|
+
if not (inplace or suffix):
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"Files must either be compressed in-place (--inplace) or you must specify a"
|
|
240
|
+
" non-empty suffix to append to the name of compressed files."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# use set() to ensure no duplicate files
|
|
244
|
+
uniq_files: list[str] = sorted({fn.replace("\\", "/").strip() for fn in filenames})
|
|
245
|
+
# for each directory received glob for all PDFs in it
|
|
246
|
+
file_paths = []
|
|
247
|
+
for file_path in uniq_files:
|
|
248
|
+
if os.path.isdir(file_path):
|
|
249
|
+
file_paths += glob(os.path.join(file_path, "**", "*.pdf*"), recursive=True)
|
|
250
|
+
else:
|
|
251
|
+
file_paths += [file_path]
|
|
252
|
+
|
|
253
|
+
# match files case insensitively ending with .pdf(,a,x) and possible white space
|
|
254
|
+
pdf_paths = [f for f in file_paths if re.match(r".*\.pdf[ax]?\s*$", f.lower())]
|
|
255
|
+
not_pdf_paths = {*file_paths} - {*pdf_paths}
|
|
256
|
+
|
|
257
|
+
if on_bad_files == "error" and len(not_pdf_paths) > 0:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"Input files must be PDFs, got {len(not_pdf_paths):,} files with "
|
|
260
|
+
f"unexpected extension: {', '.join(not_pdf_paths)}"
|
|
261
|
+
)
|
|
262
|
+
if on_bad_files == "warn" and len(not_pdf_paths) > 0:
|
|
263
|
+
print(
|
|
264
|
+
f"Warning: Got {len(not_pdf_paths):,} input files without '.pdf' "
|
|
265
|
+
f"extension: {', '.join(not_pdf_paths)}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if verbose:
|
|
269
|
+
if len(pdf_paths) > 0:
|
|
270
|
+
print(f"PDFs to be compressed with iLovePDF: {len(pdf_paths):,}")
|
|
271
|
+
else:
|
|
272
|
+
print("Nothing to do: received no input PDF files.")
|
|
273
|
+
|
|
274
|
+
if len(pdf_paths) == 0:
|
|
275
|
+
if on_no_files == "error":
|
|
276
|
+
raise ValueError("No input files provided")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
task = Compress(
|
|
280
|
+
api_key, compression_level=compression_level, debug=debug, password=password
|
|
281
|
+
)
|
|
282
|
+
task.verbose = verbose
|
|
283
|
+
|
|
284
|
+
for pdf in pdf_paths:
|
|
285
|
+
task.add_file(pdf)
|
|
286
|
+
|
|
287
|
+
task.process()
|
|
288
|
+
|
|
289
|
+
downloaded_file = task.download(save_to_dir=outdir)
|
|
290
|
+
|
|
291
|
+
task.delete_current_task()
|
|
292
|
+
|
|
293
|
+
min_size_red = min_size_reduction or (10 if inplace else 0)
|
|
294
|
+
|
|
295
|
+
if debug:
|
|
296
|
+
stats = {}
|
|
297
|
+
else:
|
|
298
|
+
stats = del_or_keep_compressed(
|
|
299
|
+
pdf_paths,
|
|
300
|
+
downloaded_file,
|
|
301
|
+
inplace=inplace,
|
|
302
|
+
suffix=suffix,
|
|
303
|
+
min_size_reduction=min_size_red,
|
|
304
|
+
verbose=verbose,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if write_stats_path:
|
|
308
|
+
try:
|
|
309
|
+
import pandas as pd # noqa: PLC0415
|
|
310
|
+
except ImportError:
|
|
311
|
+
err_msg = "To write stats to file, install pandas: pip install pandas"
|
|
312
|
+
raise ImportError(err_msg) from None
|
|
313
|
+
|
|
314
|
+
df_stats = pd.DataFrame(stats).T
|
|
315
|
+
df_stats.index.name = "file"
|
|
316
|
+
stats_path_lower = write_stats_path.strip().lower()
|
|
317
|
+
|
|
318
|
+
if ".csv" in stats_path_lower:
|
|
319
|
+
df_stats.to_csv(write_stats_path, float_format="%.4f")
|
|
320
|
+
elif ".xlsx" in stats_path_lower or ".xls" in stats_path_lower:
|
|
321
|
+
df_stats.to_excel(write_stats_path, float_format="%.4f")
|
|
322
|
+
elif ".json" in stats_path_lower:
|
|
323
|
+
df_stats.to_json(write_stats_path)
|
|
324
|
+
elif ".html" in stats_path_lower:
|
|
325
|
+
df_stats.to_html(write_stats_path, float_format="%.4f")
|
|
326
|
+
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
raise SystemExit(main())
|
pdf_compressor/utils.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from os.path import abspath, basename, dirname, expanduser, getsize, isfile, splitext
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from zipfile import ZipFile
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
ROOT = dirname(dirname(abspath(__file__)))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def si_fmt(val: float, *, binary: bool = True, fmt: str = ".1f", sep: str = "") -> str:
|
|
17
|
+
"""Convert large numbers into human readable format using SI prefixes in binary
|
|
18
|
+
(1024) or metric (1000) mode.
|
|
19
|
+
|
|
20
|
+
https://nist.gov/pml/weights-and-measures/metric-si-prefixes
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
val (int | float): Some numerical value to format.
|
|
24
|
+
binary (bool, optional): If True, scaling factor is 2^10 = 1024 else 1000.
|
|
25
|
+
Defaults to True.
|
|
26
|
+
fmt (str): f-string format specifier. Configure precision and left/right
|
|
27
|
+
padding in returned string. Defaults to ".1f". Can be used to ensure leading
|
|
28
|
+
or trailing whitespace for shorter numbers. Ex.1: ">10.2f" has 2 decimal
|
|
29
|
+
places and is at least 10 characters long with leading spaces if necessary.
|
|
30
|
+
Ex.2: "<20.3g" uses 3 significant digits (g: scientific notation on large
|
|
31
|
+
numbers) with at least 20 chars through trailing space.
|
|
32
|
+
sep (str): Separator between number and postfix. Defaults to "".
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
str: Formatted number.
|
|
36
|
+
"""
|
|
37
|
+
factor = 1024 if binary else 1000
|
|
38
|
+
_scale = ""
|
|
39
|
+
|
|
40
|
+
if abs(val) >= 1:
|
|
41
|
+
# 1, Kilo, Mega, Giga, Tera, Peta, Exa, Zetta, Yotta
|
|
42
|
+
for _scale in ("", "K", "M", "G", "T", "P", "E", "Z", "Y"):
|
|
43
|
+
if abs(val) < factor:
|
|
44
|
+
break
|
|
45
|
+
val /= factor
|
|
46
|
+
else:
|
|
47
|
+
mu_unicode = "\u03bc"
|
|
48
|
+
# milli, micro, nano, pico, femto, atto, zepto, yocto
|
|
49
|
+
for _scale in ("", "m", mu_unicode, "n", "p", "f", "a", "z", "y"):
|
|
50
|
+
if abs(val) > 1:
|
|
51
|
+
break
|
|
52
|
+
val *= factor
|
|
53
|
+
|
|
54
|
+
return f"{val:{fmt}}{sep}{_scale}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_dotenv(filepath: str | None = None) -> None:
|
|
58
|
+
"""Parse environment variables in .env into os.environ.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
filepath (str, optional): Path to .env file. Defaults to './.env'.
|
|
62
|
+
"""
|
|
63
|
+
if filepath is None:
|
|
64
|
+
filepath = os.path.join(f"{ROOT}", ".env")
|
|
65
|
+
|
|
66
|
+
if isfile(filepath):
|
|
67
|
+
with open(filepath, encoding="utf8") as dotenv:
|
|
68
|
+
for line in dotenv:
|
|
69
|
+
if line.startswith("#"):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
key, val = line.replace("\n", "").split("=")
|
|
73
|
+
os.environ[key] = val
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def del_or_keep_compressed(
|
|
77
|
+
pdfs: Sequence[str],
|
|
78
|
+
downloaded_file: str,
|
|
79
|
+
*,
|
|
80
|
+
inplace: bool,
|
|
81
|
+
suffix: str,
|
|
82
|
+
min_size_reduction: int,
|
|
83
|
+
verbose: bool = False,
|
|
84
|
+
) -> dict[str, dict[str, object]]:
|
|
85
|
+
"""Check whether compressed PDFs are smaller than original. If so, relocate each
|
|
86
|
+
compressed file to same directory as the original either with suffix appended to
|
|
87
|
+
file name or overwriting the original if inplace=True.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
pdfs (list[str]): File paths to PDFs uploaded to iLovePDF.
|
|
91
|
+
downloaded_file (str): Path to file downloaded from iLovePDF servers. Will be
|
|
92
|
+
PDF or ZIP depending on if single or multiple files were uploaded.
|
|
93
|
+
inplace (bool): Whether to overwrite original PDFs with compressed ones if
|
|
94
|
+
smaller.
|
|
95
|
+
suffix (str): String to insert after filename and before extension of compressed
|
|
96
|
+
PDFs. Used only if inplace=False.
|
|
97
|
+
min_size_reduction (int): How much compressed files need to be smaller than
|
|
98
|
+
originals (in percent) for them to be kept.
|
|
99
|
+
verbose (bool): Whether to print file names or full file paths. Defaults to
|
|
100
|
+
False.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
pd.DataFrame: Table with original and compressed file sizes.
|
|
104
|
+
""" # noqa: DOC501
|
|
105
|
+
if (n_files := len(pdfs)) == 1:
|
|
106
|
+
compressed_files = [downloaded_file]
|
|
107
|
+
else: # if multiple files were uploaded, downloaded_file is a ZIP archive
|
|
108
|
+
with ZipFile(downloaded_file) as archive:
|
|
109
|
+
# sort compressed_files since pdfs were also sorted
|
|
110
|
+
compressed_files = sorted(archive.namelist())
|
|
111
|
+
archive.extractall()
|
|
112
|
+
|
|
113
|
+
total_orig_size = total_compressed_size = 0
|
|
114
|
+
|
|
115
|
+
stats = {}
|
|
116
|
+
|
|
117
|
+
for idx, orig_path in enumerate(pdfs):
|
|
118
|
+
if n_files > 1:
|
|
119
|
+
compressed_path = next(
|
|
120
|
+
filename
|
|
121
|
+
for filename in compressed_files
|
|
122
|
+
if os.path.basename(filename).startswith(f"{idx}-")
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
compressed_path = compressed_files[0]
|
|
126
|
+
orig_size = getsize(orig_path)
|
|
127
|
+
compressed_size = getsize(compressed_path)
|
|
128
|
+
|
|
129
|
+
total_orig_size += orig_size
|
|
130
|
+
total_compressed_size += compressed_size
|
|
131
|
+
|
|
132
|
+
diff = orig_size - compressed_size
|
|
133
|
+
counter = f"\n{idx + 1} " if n_files > 1 else ""
|
|
134
|
+
|
|
135
|
+
# check if size reduction is large enough to keep compressed file and
|
|
136
|
+
# optionally move original to trash if inplace=True
|
|
137
|
+
if diff / orig_size > min_size_reduction / 100:
|
|
138
|
+
filepath = orig_path if verbose else basename(orig_path)
|
|
139
|
+
print(
|
|
140
|
+
f"{counter}'{filepath}': {si_fmt(orig_size)}B -> "
|
|
141
|
+
f"{si_fmt(compressed_size)}B which is {si_fmt(diff)}B = "
|
|
142
|
+
f"{diff / orig_size:.0%} smaller."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if inplace:
|
|
146
|
+
if sys.platform == "darwin":
|
|
147
|
+
# move original PDF to trash on macOS, for later retrieval if needed
|
|
148
|
+
print("Old file moved to trash.")
|
|
149
|
+
orig_file_name = os.path.split(orig_path)[1]
|
|
150
|
+
|
|
151
|
+
trash_path = f"{expanduser('~')}/.Trash/{orig_file_name}"
|
|
152
|
+
if isfile(trash_path): # if file with same name already in trash,
|
|
153
|
+
# delete it to avoid PermissionError: [Errno 1] Operation not
|
|
154
|
+
# permitted
|
|
155
|
+
os.remove(trash_path)
|
|
156
|
+
os.rename(orig_path, trash_path)
|
|
157
|
+
else:
|
|
158
|
+
# on other platforms, simply let shutil.move() below overwrite
|
|
159
|
+
# existing PDF
|
|
160
|
+
print("Old file deleted.")
|
|
161
|
+
|
|
162
|
+
# don't use os.(rename|replace)() on Windows, both error if src and
|
|
163
|
+
# dest are on different drives, former also if destination file already
|
|
164
|
+
# exists
|
|
165
|
+
shutil.move(compressed_path, orig_path)
|
|
166
|
+
action = "replaced original"
|
|
167
|
+
|
|
168
|
+
elif suffix:
|
|
169
|
+
base_name, ext = splitext(orig_path)
|
|
170
|
+
new_path = f"{base_name}{suffix}{ext}"
|
|
171
|
+
|
|
172
|
+
shutil.move(compressed_path, new_path)
|
|
173
|
+
action = f"saved as {basename(new_path)}"
|
|
174
|
+
|
|
175
|
+
else:
|
|
176
|
+
raise RuntimeError("This case should be not be reached")
|
|
177
|
+
else:
|
|
178
|
+
not_enough_reduction = "no" if diff == 0 else f"only {diff / orig_size:.1%}"
|
|
179
|
+
print(
|
|
180
|
+
f"{counter}'{orig_path}' {not_enough_reduction} smaller than original "
|
|
181
|
+
"file. Keeping original."
|
|
182
|
+
)
|
|
183
|
+
os.remove(compressed_path)
|
|
184
|
+
action = "kept original"
|
|
185
|
+
|
|
186
|
+
stats[basename(orig_path)] = {
|
|
187
|
+
"original size (B)": orig_size,
|
|
188
|
+
"compressed size (B)": compressed_size,
|
|
189
|
+
"size reduction (B)": diff,
|
|
190
|
+
"size reduction (%)": diff / orig_size,
|
|
191
|
+
"action": action,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# remove ZIP archive and unused compressed PDFs
|
|
195
|
+
for filename in (*compressed_files, downloaded_file):
|
|
196
|
+
try:
|
|
197
|
+
os.remove(filename)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
# print overall size reduction if >= 2 file
|
|
202
|
+
overall_reduction = total_orig_size - total_compressed_size
|
|
203
|
+
show_summary_above_n_files = 2
|
|
204
|
+
if n_files > show_summary_above_n_files and overall_reduction > 0:
|
|
205
|
+
print(
|
|
206
|
+
f"Overall size reduction in {n_files} files: {si_fmt(overall_reduction)}B, "
|
|
207
|
+
f"from {si_fmt(total_orig_size)}B to {si_fmt(total_compressed_size)}B"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return stats
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf-compressor
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: CLI + Python API for batch compressing PDFs with iLovePDF.com
|
|
5
|
+
Author-email: Janosh Riebesell <janosh.riebesell@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2021 Janosh Riebesell
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
The software is provided "as is", without warranty of any kind, express or
|
|
21
|
+
implied, including but not limited to the warranties of merchantability,
|
|
22
|
+
fitness for a particular purpose and noninfringement. In no event shall the
|
|
23
|
+
authors or copyright holders be liable for any claim, damages or other
|
|
24
|
+
liability, whether in an action of contract, tort or otherwise, arising from,
|
|
25
|
+
out of or in connection with the software or the use or other dealings in the
|
|
26
|
+
software.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/janosh/pdf-compressor
|
|
29
|
+
Project-URL: Package, https://pypi.org/project/pdf-compressor
|
|
30
|
+
Keywords: cli,compression,ilovepdf,pdf
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
38
|
+
Requires-Python: >=3.11
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
Requires-Dist: requests>=2.25.0
|
|
41
|
+
Provides-Extra: test
|
|
42
|
+
Requires-Dist: pytest; extra == "test"
|
|
43
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
44
|
+
Provides-Extra: stats
|
|
45
|
+
Requires-Dist: pandas; extra == "stats"
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<img src="https://raw.githubusercontent.com/janosh/pdf-compressor/main/assets/pdf-compressor.svg" alt="PDF Compressor" height=150>
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<h3 align="center">
|
|
52
|
+
|
|
53
|
+
[](https://github.com/janosh/pdf-compressor/actions)
|
|
54
|
+
[](https://results.pre-commit.ci/latest/github/janosh/pdf-compressor/main)
|
|
55
|
+
[](https://pypi.org/project/pdf-compressor)
|
|
56
|
+
[](https://python.org/downloads)
|
|
57
|
+
[](https://pypistats.org/packages/pdf-compressor)
|
|
58
|
+
|
|
59
|
+
</h3>
|
|
60
|
+
|
|
61
|
+
Command line PDF compression powered by the free [iLovePDF API](https://developer.ilovepdf.com).
|
|
62
|
+
|
|
63
|
+
**Why?** iLovePDF delivers the best PDF compression I've seen but their web UI doesn't support automation and can be cumbersome when dealing with many files.
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
pip install pdf-compressor
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
First, tell `pdf-compressor` your iLovePDF API key (if you haven't yet, get one by signing up at <https://developer.ilovepdf.com/signup>):
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
pdf-compressor --set-api-key project_public_7c854a9db0...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then start compressing!
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
pdf-compressor **/*.pdf
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Options
|
|
86
|
+
|
|
87
|
+
| Flags | Default | Description |
|
|
88
|
+
| -------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `-i/--inplace` | `False` | Whether to compress PDFs in place. |
|
|
90
|
+
| `-s/--suffix` | `'-compressed'` | String to append to the filename of compressed PDFs. Mutually exclusive with `--inplace`. |
|
|
91
|
+
| `--cl/--compression-level` | `'recommended'` | How hard to squeeze the file size. One of `'low'`, `'recommended'`, `'extreme'`. `'extreme'` noticeably degrades quality of embedded bitmap images. |
|
|
92
|
+
| `--set-api-key` | | Set the public key needed to authenticate with the iLovePDF API. You only need to do this once to start using. |
|
|
93
|
+
| `--report-quota` | | Report the number of remaining file operations in the current billing cycle for the stored iLovePDF API key. |
|
|
94
|
+
| `--debug` | `False` | When true, iLovePDF won't process the request but only reports the parameters that would have been sent to the server. |
|
|
95
|
+
| `--verbose` | `False` | When true, progress will be reported while tasks are running. |
|
|
96
|
+
| `-v/--version` | | Get the version number of `pdf-compressor`. |
|
|
97
|
+
| `--on-no-files` | `'ignore'` | What to do when no input PDFs received. One of `'ignore'` or `'error'`, former exits 0, latter raises `ValueError`. Can be useful when using `pdf-compressor` in shell scripts. |
|
|
98
|
+
| `--on-bad-files` | `'error'` | How to behave when receiving input files that don't appear to be PDFs. One of `'error'`, `'warn'`, `'ignore'`. Error type will be `ValueError`. Code used to check if something is PDF: `filepath.lower().endswith(".pdf")` |
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pdf_compressor/__init__.py,sha256=sLqwhVZfWzCGfayiRBLS1c4BxVDI-6Y-yaSB3Ceew4s,331
|
|
2
|
+
pdf_compressor/ilovepdf.py,sha256=ORJ6xkMiMml5_EgFce5LuqcJQOnhd84aWqcLm2jHdCs,11929
|
|
3
|
+
pdf_compressor/main.py,sha256=Y5wj0XbRkQzVwoUQB8tmIe_rvyt7O6R5694x7dn52Iw,11236
|
|
4
|
+
pdf_compressor/utils.py,sha256=JgoGzbDErRkw12eR-L9ghUJF-LhIuWwu_03laLH-HKs,8083
|
|
5
|
+
pdf_compressor-0.4.0.dist-info/METADATA,sha256=drEIXQ0qBs8EL2InntTzWF_msegcZ0b8_Bos8-HK1uM,6972
|
|
6
|
+
pdf_compressor-0.4.0.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
|
7
|
+
pdf_compressor-0.4.0.dist-info/entry_points.txt,sha256=NPlZlhyjfB1Ut_-twe-BdIKm4_VokXGxqM1wj2oskMs,55
|
|
8
|
+
pdf_compressor-0.4.0.dist-info/top_level.txt,sha256=06mKqczyFT7D3p3ZhfvR4wRFglo3E3ycL4n-V2ychbA,15
|
|
9
|
+
pdf_compressor-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pdf_compressor
|