← Articles

PDF Compressor: shipping a command, then watching someone use it

The itch showed up as a message. A colleague had a PDF that needed to go through a submission portal with a hard file-size ceiling, and it was just over the line. I already had the fix — a small command-line interface (CLI) I use daily, pdf compress, that runs the file through Ghostscript and, on the client decks I’ve checked, usually hands back something much smaller without a difference I can see. For me it’s one command. The problem is that “one command” is the part he can’t run.

So the real task was never compression. That was solved months ago. The task was the gap between a command that works on my machine and a thing a small group of non-technical people can double-click: no Terminal, no “first install Ghostscript” that quietly assumes they know what any of that means. The compression is a solved Ghostscript invocation. Everything interesting is in carrying it to another Mac with its dependencies intact, and doing it so the person on the other end never has to learn what those dependencies are.

That took two builds. The first one shipped. Then someone used it, and I found out which half of my reasoning had been right.

Reuse the engine, don’t rewrite it

The tempting rewrite was pure Python: pikepdf and Pillow, resample the images, rewrite the streams, ship a tidy little script with no compiled binary hanging off it. It would have dodged two things I knew were coming: Ghostscript’s tree of dynamic libraries and the licensing questions that come with bundling Ghostscript under the GNU Affero General Public License (AGPL).

I didn’t do it, because both of those costs were still smaller than the rewrite. The gs command in my CLI is proven in my own work. It’s the invocation I already use on real client decks, and Ghostscript has handled odd color spaces and half-broken design-tool exports better than anything I’d reassemble from Python libraries in a weekend. Rewriting the engine to avoid packaging the engine felt like solving the wrong problem.

This is the decision that has survived everything since. Two releases later the engine is still Ghostscript 10.07.1, still the /ebook preset, still PDF 1.4 out. Every part of the app that changed is a part I built around it.

Nothing to freeze

The reflex for “bundle a Python thing into a Mac app” is PyInstaller. It would have been the wrong tool here for a precise reason: there’s no meaningful Python to freeze. The entire compression path was a single subprocess call out to gs. PyInstaller’s job is to pack an interpreter and the code around it so a machine without Python can still run it, but the interpreter isn’t doing the work. The compiled binary is. I’d be bundling a whole runtime to babysit one process call.

The actual problem was smaller and more specific: I had a compiled binary, gs, whose dynamic libraries lived at Homebrew paths that would not be dependable on another Mac. That’s a relocation problem, and there’s a tool built for it. dylibbundler walks the binary’s dependency tree, copies the libraries into the bundle, and rewrites their install names to relative @executable_path paths so the binary can find them wherever the app lands.

So the first build was two pieces: Platypus wrapping a droplet around the shell script, and dylibbundler making gs portable underneath it. Worth separating those two, because they did not age the same way. The relocated payload is still what ships today. The droplet is gone.

Signed, because the escape hatch closed

The lazy version of this ships unsigned and tells people to right-click and choose Open the first time. On current macOS versions that route is no longer dependable for software that is not signed correctly or notarized; the user may have to go into System Settings → Privacy & Security and explicitly allow it. Per machine, and potentially again after a changed build is treated as something new.

For a non-technical group that’s not a one-time cost. It’s a recurring support tax I’d be signing myself up to collect. So the app is signed with a Developer ID certificate and put through Apple’s notarization, with the ticket stapled to the bundle. Stapling lets Gatekeeper find the notarization ticket even when the Mac is offline. A downloaded copy may still get the ordinary first-open confirmation, but it no longer needs the more alarming route through System Settings. The setup work lives on my machine so that the hard part doesn’t live on theirs.

Proving relocation, narrowly

The relocation investigation started when the person I built the app for tried the first version. He dropped in the PDF, nothing visibly happened, and an AppleScript error appeared. From his side, that was the whole result. From mine, it left two failures tangled together: whether Ghostscript had failed to run, or whether the wrapper had failed to show what it was doing.

I pulled the compression path out of the wrapper and tested the payload directly. I bundled gs 10.07.1 with its relocated dynamic-library dependencies, stripped PATH down to /usr/bin:/bin, and ran the command against the real deck that had triggered the project. It exited successfully and produced a result comfortably below the portal limit. The output had the same byte count as a result produced through the system gs, which was a useful signal but not proof that the files were equivalent. Same size is not the same file.

More importantly, this was not a clean-machine test. Homebrew was still installed on the Mac; the constrained PATH only showed that the process was not finding gs there. It proved that the relocated executable and libraries could run under that constraint. It did not prove that the bundle contained everything a Mac with no Homebrew installation would need.

One suspected problem also failed to show up, and the non-event was still useful. I’d expected trouble from fontconfig, with Ghostscript reaching for font paths that might not exist elsewhere. It did not happen in the deck I used because its fonts were embedded. That narrowed the test: it was evidence for this file, not proof that missing-font and substitution cases were solved.

Following the failure all the way through exposed the clean-machine gap. A self-contained Ghostscript needs three things: the executable, every relocated library, and Ghostscript’s runtime resource tree. The first release shipped the first two. It worked on my machine because the Homebrew copy of share/ghostscript was still there, invisibly holding it up. v1.0.1 copies the full resource tree and points GS_LIB at the bundled version. A dependency you never see is one you never think to pack.

What broke was the wrapper

v1.0.1 shipped, signed and notarized and installable. Then a non-technical colleague used it, and two things came apart. Neither of them was compression.

The first was feedback. The droplet used osascript to fire a “done, here’s the new size” notification, and that path was unreliable. When it misbehaved it did the worst possible thing: it exposed Script Editor behavior to someone with no frame of reference for what Script Editor is. A tool whose promise was “you don’t have to know what’s under here” had started leaking what was under there.

The second was quieter and more fundamental. A Platypus droplet gives you a Finder drag target and little dependable feedback after that. No queue, no per-file progress, no clean success or failure state. Drop a file, then wait and hope.

The tempting fix was to patch it: replace the notification, pipe script output to a status surface, add a helper process to hold state. It would have kept the interface living inside scripts and helpers instead of inside the app. Every future question about what the user sees would have been answered by bolting one more fragile thing onto the side. The wrapper wasn’t underbuilt. It was the wrong shape for what the app had turned out to need.

So 1.1.0 replaced it with a native app: SwiftUI for the visible window, which is a drop target plus a job list with queued, compressing, complete, and failed states, before-and-after sizes, and Reveal in Finder. An AppKit application delegate catches files opened through Finder or dropped on the Dock icon and feeds them into the same queue. Compression now launches Ghostscript directly through Swift’s Process with an argument array. The Ghostscript options stayed the same; the shell layer and its interpolation disappeared.

What’s striking is how little else moved. Same Ghostscript 10.07.1, same relocated libraries, same resource tree, same /ebook preset, same bundle identifier, same one job at a time. The app writes each result to a temporary path, validates that it is nonempty and has the expected page count, then atomically moves it into the final compressed-copy path. The source PDF is never the replacement target. Signing, notarization, and stapling stayed in the same release pipeline. The rewrite was surgical because the reasoning underneath it had held. I only had to throw away the layer that was making promises to a human.

Everything reports; nothing asks

The original build wanted a notification for a reason that outlived the notification. Someone watching a progress-free window has no way to tell working from hung, and after a while they assume hung.

osascript was a bad answer to a real question. Once the interface lived in the app, 1.1.1 could read Ghostscript’s own page-by-page output and turn it into visible progress, plus a conservative initial estimate of remaining time that gives way to a rolling estimate as pages complete. The fix for a black box was never a better message announcing that the box had finished. It was making the box not black.

The app now shows a queue, per-file status, sizes, progress, and a time estimate. It still has no settings, no quality slider, no presets, no menu of options. Everything reports; nothing asks. The visible surface got larger while the decision surface stayed at zero, which was always the design: for the people this is for, every option is a chance to pick the wrong one.

The part no gate checks

Every release gate I have is automated, and the mistakes that embarrassed me sailed through all of them.

One was a warning that lied. Platypus 5.5.0 looked at the .icns produced by Xcode’s asset compiler and said it did not appear to be an Apple icon file. It was wrong: Finder rendered the icon correctly, and the bundle passed signing, notarization, stapling, and Gatekeeper. Changing a valid artifact to satisfy an outdated wrapper would have been the real bug, so I left it alone. Platypus is gone now; the lesson stayed.

The icon itself still had a tiny visual defect that no release gate could see. Then the first native release arrived with development-only copy visible in the production window and an incomplete icon set. Correcting the executable meant putting the replacement app and disk image (DMG) through notarization and stapling again. Everything automated had passed. It took a person looking at the window to notice the app was talking to itself.

The license line I can actually see

Ghostscript is AGPLv3. The operational facts around this app are deliberately narrow: it is used by employees inside one legal entity, on local Macs, with no contractors, affiliates, external recipients, public download, or network service in the current path.

That fact pattern is not legal clearance, and a private repository would not make it so. It is simply the boundary under which the app is operated today. The release record needs to keep the Ghostscript source and notices alongside the dependency information, and the bundled libraries have their own licenses and notices to track.

If the recipient group, legal-entity boundary, delivery method, or local-only use changes, distribution pauses for renewed legal or Artifex review before the app moves. External conveyance may bring corresponding-source and notice obligations into the foreground; a hosted version presents a different set of questions. The point of writing the boundary down is not to settle those questions in advance. It is to make sure a small internal utility does not drift into a different fact pattern one recipient at a time.

The native rewrite did not change that boundary. The reasoning survived a change of language and framework because it was never about the wrapper.

Where it landed

1.1.1 is a native app that installs to /Applications/PDF Compressor.app. Drop a PDF on the window, open one with it from Finder, or drop one on the icon, and a compressed copy appears beside the original with the size change shown. The original stays untouched. The result is often smaller on the decks this was built around, but the interface reports what happened rather than promising that every PDF will shrink.

The release running now is signed with a Developer ID certificate, notarized, stapled, and checked with spctl in both its ZIP and mounted-DMG forms. Rollout is still canary first: one non-technical colleague gets the installer and a one-line instruction, then confirms install, launch, drop, output, and that a deliberate failure reads as understandable before the app goes any wider.

Distribution had one more layer that notarization could not touch: the intended users still needed a place where they could actually reach the installer. It now lives in an internal channel they can access. Being allowed to run the file and being able to find it were two different problems, and only one of them was ever about Ghostscript. The last droplet release stays frozen as the rollback.

The compression was never the hard part. The Ghostscript argument set existed before the first message and survived the rewrite from a shell-driven droplet to a native Process call. What changed is everything that touches a person, which is also the only part real usage could have found for me. When it’s done right, the app says what it is doing, asks nothing unnecessary, and leaves the machinery underneath where it belongs.