A small web app for the things you actually do with PDFs:
- Compress — reduce file size by picking a quality preset or a target size in MB.
- PDF → JPG — render every page as a JPEG (single page → single JPG; multi-page → ZIP).
- JPG → PDF — combine images into a single PDF, in the order you upload them.
- Merge — concatenate multiple PDFs, no re-encoding of pages.
- Split — one PDF per page, or split by custom ranges (
1-3, 5, 8-10).
Everything runs locally. Files never leave your machine.
- Python 3.14+
- uv — fast Python package manager
- macOS/Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh - macOS:
brew install poppler - Debian/Ubuntu:
sudo apt install poppler-utils
- macOS/Linux:
uv syncuv run uvicorn quire:app --port 8000For development with auto-reload:
uv run uvicorn quire:app --reload --port 8000docker build -t quire .
docker run -p 8000:8000 quire| Operation | Approach |
|---|---|
| Compress | pdftoppm -scale-to N → JPEG → img2pdf (no double-encoding) |
| Compress (target) | Walks a quality ladder from highest → lowest; stops at first fit |
| PDF → JPG | pdftoppm -jpeg -r DPI |
| JPG → PDF | img2pdf (JPEGs pass through losslessly; other formats re-encoded) |
| Merge | pypdf (lossless, page-level concatenation) |
| Split | pypdf |
Using long-edge pixel count (-scale-to) instead of DPI (-r) keeps output
sizes predictable even when the source PDF has unusually large page dimensions
(scanned documents sometimes wrap a single image on a 24×31-inch page).
The app includes rate limiting, file validation, security headers, and a concurrency cap. For public-facing deployments, add bot filtering at the reverse proxy layer.
In NPM, open your proxy host and paste the following in the Advanced tab:
# Block abusive bots
if ($http_user_agent ~* (SemrushBot|AhrefsBot|MJ12bot|DotBot|BLEXBot|DataForSeoBot|GPTBot|OAI-SearchBot|ChatGPT-User|CCBot|ClaudeBot|Bytespider|PetalBot|Scrapy|wget|curl|python-requests)) {
return 444;
}
# Block empty User-Agent
if ($http_user_agent = "") {
return 444;
}
# Block common scanner paths
location ~* (\.php|\.asp|\.env|wp-admin|wp-login|\.git) {
return 444;
}
# Cap upload size (matches app limit)
client_max_body_size 100m;For Nginx-level rate limiting, create /data/nginx/custom/http_top.conf
on the NPM host:
limit_req_zone $binary_remote_addr zone=quire:10m rate=30r/m;Then add to the same Advanced tab:
limit_req zone=quire burst=20 nodelay;
limit_req_status 429;- Upload size is capped at 100 MB per file (
MAX_UPLOAD_MBinapp.py). - Working files live under your OS temp dir (
quire_work/) and are cleaned up after each request and swept by a background thread hourly. - API endpoints are rate-limited to 10 requests/min per IP.
- At most 4 processing jobs run concurrently; additional requests wait.
- The app binds to localhost only by default. If you want to expose it on
your network, run with
--host 0.0.0.0— but be aware there is no authentication built in.