Attached is a macro which compresses and encrypts PDF files with three trigger options acting on one or more files that are selected in Finder:
- ⌃⌥C = compression + overwrite
- ⌃⌥E = compression + encryption (default password) + overwrite
- ⌃⌥U = user parameter section for compression, delete original, encryption, overwrite and custom password
All options have fairly verbose messaging (i.e., compression %, and parameter settings).
Compress PDF.kmmacros (58.9 KB)
In addition to the Keyboard Maestro macro you will need Ghostscript (easily installed via Homebrew by typing "brew install ghostscript" without quotes in Terminal) and the below Python Script:
Summary
#!/usr/bin/env python3
"""
20260329_pdf_compress.py
PDF compression and optional encryption utility.
Called by Keyboard Maestro macro "Compress PDF" via Execute Shell Script action.
All inputs are received via KMVAR_ environment variables.
Output is a single result string printed to stdout, captured by KM into
Local_ScriptResults and displayed as a macOS notification.
Triggers:
⌥⌘C — Compress and overwrite (silent)
⌥⌘E — Compress, encrypt with keychain password, overwrite (silent)
⌥⌘U — User-specified parameters per file
Author: Joel
Date: 2026-03-29
"""
import os
import sys
import subprocess
import tempfile
import shutil
# ── Helpers ───────────────────────────────────────────────────────────────────
def get_env(key, default=''):
"""Read a KMVAR_ environment variable, stripping surrounding whitespace."""
return os.environ.get(f'KMVAR_{key}', default).strip()
def is_yes(value):
"""Return True if the value represents a Yes/true flag."""
return value.strip().lower() in ('yes', '1', 'true')
def format_size(size_bytes):
"""Format a byte count as a human-readable string (KB or MB)."""
if size_bytes >= 1_048_576:
return f'{size_bytes / 1_048_576:.1f} MB'
return f'{size_bytes / 1024:.0f} KB'
def first_meaningful_line(text):
"""Return the first non-empty, non-whitespace line from a block of text."""
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return text.strip()
def trash_file(path):
"""Move a file to the macOS Trash via osascript."""
script = (
f'tell application "Finder" to delete '
f'(POSIX file "{path}" as alias)'
)
result = subprocess.run(
['osascript', '-e', script],
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(
f'Could not move original to Trash: '
f'{first_meaningful_line(result.stderr)}'
)
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
# ── 1. Read inputs ────────────────────────────────────────────────────────
source_path = get_env('Local_SourceFilePath')
dest_path = get_env('Local_DestinationFilePath')
do_compress = is_yes(get_env('Local__Compress', 'Yes'))
do_encrypt = get_env('Local__Encrypt', 'No') == 'Yes'
password = get_env('Local_EncryptionKey')
do_delete = is_yes(get_env('Local__Delete', 'No'))
do_overwrite = is_yes(get_env('Local__Overwrite', 'No'))
file_name = os.path.basename(source_path)
# ── 2. Validate ───────────────────────────────────────────────────────────
if not source_path:
print('ERROR: Source file path is empty. The macro did not process a file.')
sys.exit(0)
if not os.path.isfile(source_path):
print(f'ERROR: {file_name} — Source file not found.')
sys.exit(0)
if do_encrypt and not password:
print(f'ERROR: {file_name} — Encryption requested but no password provided.')
sys.exit(0)
# ── 3. Record original size ───────────────────────────────────────────────
orig_size = os.path.getsize(source_path)
# ── 4. Locate Ghostscript ─────────────────────────────────────────────────
gs_path = shutil.which('gs')
if not gs_path:
for candidate in ('/usr/local/bin/gs', '/opt/homebrew/bin/gs'):
if os.path.isfile(candidate):
gs_path = candidate
break
if not gs_path:
print(f'ERROR: {file_name} — Ghostscript not found. '
f'Install via: brew install ghostscript')
sys.exit(0)
# ── 4b. Locate qpdf (used for encryption) ────────────────────────────────
qpdf_path = None
if do_encrypt:
qpdf_path = shutil.which('qpdf')
if not qpdf_path:
for candidate in ('/usr/local/bin/qpdf', '/opt/homebrew/bin/qpdf'):
if os.path.isfile(candidate):
qpdf_path = candidate
break
if not qpdf_path:
print(f'ERROR: {file_name} — qpdf not found. '
f'Install via: brew install qpdf')
sys.exit(0)
# ── 5. Build Ghostscript command (compression only — no encryption) ───────
tmp_fd, tmp_path = tempfile.mkstemp(suffix='.pdf')
os.close(tmp_fd)
cmd = [
gs_path,
'-sDEVICE=pdfwrite',
'-dNOPAUSE',
'-dBATCH',
'-dQUIET',
'-dCompatibilityLevel=1.7',
'-dEmbedAllFonts=true',
'-dSubsetFonts=true',
'-dCompressFonts=true',
'-dDetectDuplicateImages=true',
]
if do_compress:
cmd += [
'-dPDFSETTINGS=/ebook',
]
cmd += [
f'-sOutputFile={tmp_path}',
source_path,
]
# ── 6. Run Ghostscript ────────────────────────────────────────────────────
try:
gs_result = subprocess.run(cmd, capture_output=True, text=True)
if gs_result.returncode != 0:
stderr_msg = first_meaningful_line(gs_result.stderr) or 'Unknown error'
print(f'ERROR: {file_name} — Ghostscript failed: {stderr_msg}')
os.unlink(tmp_path)
sys.exit(0)
except Exception as e:
print(f'ERROR: {file_name} — Ghostscript could not be run: {e}')
try:
os.unlink(tmp_path)
except OSError:
pass
sys.exit(0)
# ── 7. Validate Ghostscript output ────────────────────────────────────────
try:
gs_size = os.path.getsize(tmp_path)
except Exception as e:
print(f'ERROR: {file_name} — Could not read compressed file size: {e}')
try:
os.unlink(tmp_path)
except OSError:
pass
sys.exit(0)
if gs_size == 0:
print(f'ERROR: {file_name} — Ghostscript produced an empty file.')
os.unlink(tmp_path)
sys.exit(0)
# ── 7b. Run qpdf for encryption (AES-256, full permissions) ──────────────
if do_encrypt:
enc_fd, enc_path = tempfile.mkstemp(suffix='.pdf')
os.close(enc_fd)
try:
qpdf_cmd = [
qpdf_path,
'--linearize',
'--encrypt', password, password, '256', '--',
tmp_path,
enc_path,
]
qpdf_result = subprocess.run(qpdf_cmd, capture_output=True, text=True)
if qpdf_result.returncode != 0:
stderr_msg = first_meaningful_line(qpdf_result.stderr) or 'Unknown error'
print(f'ERROR: {file_name} — qpdf encryption failed: {stderr_msg}')
os.unlink(tmp_path)
os.unlink(enc_path)
sys.exit(0)
except Exception as e:
print(f'ERROR: {file_name} — qpdf could not be run: {e}')
try:
os.unlink(tmp_path)
os.unlink(enc_path)
except OSError:
pass
sys.exit(0)
# GS temp no longer needed — replace with encrypted temp
os.unlink(tmp_path)
tmp_path = enc_path
# ── 8. Get final output size ──────────────────────────────────────────────
try:
new_size = os.path.getsize(tmp_path)
except Exception as e:
print(f'ERROR: {file_name} — Could not read output file size: {e}')
try:
os.unlink(tmp_path)
except OSError:
pass
sys.exit(0)
if new_size == 0:
print(f'ERROR: {file_name} — Output file is empty.')
os.unlink(tmp_path)
sys.exit(0)
# ── 9. Move output to final destination ───────────────────────────────────
try:
if do_overwrite:
os.replace(tmp_path, source_path)
final_path = source_path
else:
shutil.move(tmp_path, dest_path)
final_path = dest_path
except Exception as e:
print(f'ERROR: {file_name} — Could not write output file: {e}')
try:
os.unlink(tmp_path)
except OSError:
pass
sys.exit(0)
# ── 10. Delete original if requested (only when not overwriting) ──────────
deleted = False
if do_delete and not do_overwrite:
try:
trash_file(source_path)
deleted = True
except RuntimeError as e:
print(f'WARNING: {file_name} — processed successfully but '
f'could not trash original: {e}')
sys.exit(0)
# ── 11. Build result message ──────────────────────────────────────────────
orig_str = format_size(orig_size)
new_str = format_size(new_size)
if new_size < orig_size:
pct = round((1 - new_size / orig_size) * 100)
stats = f'{orig_str} → {new_str} ({pct}% smaller)'
else:
stats = f'no size reduction achieved ({orig_str} → {new_str})'
encrypt_str = 'encrypted' if do_encrypt else 'not encrypted'
if do_overwrite:
dest_str = 'overwritten'
deletion_str = ''
else:
dest_filename = os.path.basename(final_path)
dest_str = f'saved: {dest_filename}'
deletion_str = ' | original deleted' if deleted else ' | original retained'
result = (
f'{file_name} | {stats} | '
f'{encrypt_str} | '
f'{dest_str}'
f'{deletion_str}'
)
print(result)
sys.exit(0)
if __name__ == '__main__':
main()
Happy to answer any questions.
I hope this is helpful to others.