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.
@@ -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())
@@ -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
+ [![Tests](https://github.com/janosh/pdf-compressor/workflows/Tests/badge.svg)](https://github.com/janosh/pdf-compressor/actions)
54
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/janosh/pdf-compressor/main.svg)](https://results.pre-commit.ci/latest/github/janosh/pdf-compressor/main)
55
+ [![PyPI](https://img.shields.io/pypi/v/pdf-compressor?logo=pypi&logoColor=white)](https://pypi.org/project/pdf-compressor)
56
+ [![Requires Python 3.11+](https://img.shields.io/badge/Python-3.11+-blue.svg?logo=python&logoColor=white)](https://python.org/downloads)
57
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/pdf-compressor?logo=icloud&logoColor=white)](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,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdf-compressor = pdf_compressor:main
@@ -0,0 +1 @@
1
+ pdf_compressor