DEV Community

shenghua wu
shenghua wu

Posted on

208 file tools that never upload your files — and the 7 bugs that taught us how

Disclosure: I build Tool Pantry with AI coding agents, and this article was drafted with AI assistance from my own engineering notes and test logs. Every technical claim below — file sizes, byte counts, pixel diffs, MD5s — was verified against the source code or real test output, and I've marked the two places where I had to correct an earlier conclusion.

Every "free online PDF tool" works the same way: you pick a file, it uploads to their server, they process it, you download the result. That's fine until you're handling a passport scan, a signed contract, or a medical record.

We built Tool Pantry on the opposite premise: 208 tools, zero uploads. PDF, images, video, audio, text. Nothing ever leaves the device — not by policy, by architecture.

That constraint turned out to be a much better engineering problem than we expected. Here are the seven things that actually bit us.


1. The 25 MiB wall — why we abandoned ffmpeg.wasm

Video compression was the first "obviously we'll use the obvious library" decision. ffmpeg.wasm is the standard answer for browser video.

It died at deploy time.

ffmpeg-core.wasm is 30.7 MiB. Cloudflare Workers caps a single asset at 25 MiB. Not a soft limit — the deploy just fails. We could have moved off Workers to keep the library, but shipping a 30 MB download to every visitor is its own problem.

So we rebuilt the pipeline on native browser primitives:

Stage Library Size
Demux MP4Box.js ~200 KB
Decode / scale / encode WebCodecs (VideoDecoder, VideoEncoder) built in
Mux mp4-muxer ~50 KB

Total: ~390 KB instead of ~30 MB — a 77× reduction. And it's not just smaller, it's faster, because WebCodecs hits hardware encoders instead of compiling x264 to WASM.

Verified output: 1280×720 6s source → 854×480 H.264 Baseline + AAC, 1.81 MB, confirmed with ffprobe picking apart the actual container.

Lesson: the ecosystem's obvious library was written when the platform couldn't do the job. Check whether the platform caught up before you ship the workaround.


2. pdf-lib's removePage doesn't delete anything

Deleting a page from a PDF should make the file smaller. Ours got bigger:

793 KB  →  remove one page  →  812 KB
Enter fullscreen mode Exit fullscreen mode

removePage() unlinks the page from the page tree, but the page's content stream, images, and fonts stay in the file — reachable through the incremental-update structure. The bytes are still there. Forensically, the "deleted" page is still in your document.

The fix is a rebuild: create a fresh PDFDocument, copyPages() only the pages you're keeping, and save that.

812 KB  →  copyPages rebuild  →  510 KB
Enter fullscreen mode Exit fullscreen mode

A 37% reduction — the data genuinely gone.

The side effect is worth disclosing to users: rebuilding a PDF drops its metadata (author, producer, creation dates). We say so on the page rather than letting people discover it.

Lesson: in PDF, "delete" almost always means "unlink." If a user's threat model includes recovering deleted content, unlinking is not enough.


3. constructor.name is a lie in minified builds

We shipped a PDF form-filler that detects AcroForm fields — text, checkbox, dropdown, radio — and renders the right input for each.

It worked perfectly in development and detected nothing in production.

Root cause: pdf-lib ships minified. In the minified build, every class's constructor.name is "n". Our type dispatch (field.constructor.name === 'PDFTextField') matched nothing.

The fix is instanceof against the exported classes, which survives minification because it compares actual prototype chains:

var isText     = field instanceof PDFLib.PDFTextField;
var isCheck    = field instanceof PDFLib.PDFCheckBox;
var isDropdown = field instanceof PDFLib.PDFDropdown;
var isOptionList = field instanceof PDFLib.PDFOptionList;
var isRadio    = field instanceof PDFLib.PDFRadioGroup;
Enter fullscreen mode Exit fullscreen mode

Lesson: never dispatch on constructor.name for anything that ships minified — including your own code, if a bundler might rename it. instanceof or an explicit discriminant field, always.


4. MP4 rotation lives in the header, not the pixels

A phone video shot in portrait is usually stored landscape, with a rotation matrix in the tkhd box telling the player "turn this 90°." The pixels never change; the metadata does.

MP4Box.js exposes both, and they disagree:

  • track.track_width / track_height → the display size from tkhd
  • videoTrack.video.width / height → the encoded size from stsd

We used the first one everywhere. Every operation on a rotated video was wrong — crops landed in the wrong place, blends misaligned, output dimensions swapped.

Once we understood the split, a better option appeared for the operations that don't need pixel access. Rotating a video isn't decoding 30 million pixels and re-encoding them — it's rewriting one 3×3 matrix in a header.

We rewrite the tkhd matrix directly. Verified: byte-identical result to re-encoding with transpose=1, pixel difference 0.0. It finishes in 0.0 seconds on a 6-second clip, because nothing is decoded.

Same trick for muting: stream-copy the video track, drop the audio track. The output video bitstream's MD5 is identical to the source. Lossless, verifiable, instant.

Lesson: before you write the pixel loop, check whether the answer is already a number in a header.


5. The color bug that wasn't — verify your measurement tool first

This one nearly cost us a working pipeline.

We compared input and output video frames pixel by pixel and got a clean, damning result:

output ≈ 0.81 × source + 23
Enter fullscreen mode Exit fullscreen mode

That formula is the signature of a YUV↔RGB range mismatch — the classic full-range (0–255) vs. limited-range (16–235) bug. Textbook. So we started hunting for it: checked the VUI and colr atoms, inspected our VideoFrame color-space options, ran encode round-trips, scanned for time-alignment offsets, generated difference maps.

We found nothing, because there was nothing to find.

ffprobe was reading our untagged source file and guessing the YUV matrix. It guessed wrong. The "0.81× + 23" was an artifact of the measuring tool, not a defect in our code.

The real test: decode source and output in the same decoder (Chrome) and compare flat regions — delta ≤ 3, which is just normal lossy-compression noise.

We were one commit away from "fixing" a correct color pipeline and breaking it.

Lesson: when a measurement gives you a textbook-perfect diagnosis, be suspicious. Confirm the tool before you trust the verdict.


6. SHA256 is not SHA-256

await crypto.subtle.digest('SHA256', data);
// → NotSupportedError
Enter fullscreen mode Exit fullscreen mode

WebCrypto wants the hyphenated form: 'SHA-256'. The un-hyphenated spelling fails with an error that gives you no hint about the format being the problem.

Our hash tool now maps friendly names to the exact WebCrypto identifiers:

const webcryptoNames = {
  sha1: 'SHA-1', sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512'
};
Enter fullscreen mode Exit fullscreen mode

We checked the output byte-for-byte against Python's hashlib to be sure. It matches.


7. Transparent canvas pixels have garbage color channels

The signature tool has a mode that turns a photo of a signature on paper into a clean transparent PNG: threshold away the paper, keep the ink, recolor, auto-crop.

Bug: it kept hallucinating ink in empty areas.

Cause: in a canvas, a fully transparent pixel can still have color values — and drawing operations often leave them at 0,0,0 (black). Our threshold ("dark pixels = ink") read the entire transparent region as ink.

Fix: gate on alpha before thresholding.

if (alpha === 0) continue;  // never trust the color of an invisible pixel
Enter fullscreen mode Exit fullscreen mode

Lesson: transparent pixels are not "empty." Their color channels are whatever the last write left behind. Always check alpha first.


What this actually costs you (the honest section)

Client-side isn't free. The real trade-offs:

  • Download size. Background removal needs a U²-Net model via ONNX Runtime Web — about 15 MB, lazy-loaded. OCR needs Tesseract WASM — about 8 MB. First use of those two tools is a real wait.
  • Browser floors. The video tools need WebCodecs — Chrome/Edge, and Safari 17+. Older browsers can have the PDF and image tools, not the video ones.
  • Threading requires headers. ONNX Runtime's multithreaded path needs COOP: same-origin + COEP: require-corp. That's fine for a site with no third-party embeds, and it means those two headers are now load-bearing.
  • And there's a ceiling. Anything that genuinely needs a large model or a server-side API — LLM summarization, translation, image generation — we don't build. We say so instead of shipping a fake version.

But the floor is much higher than it was two years ago, and WebCodecs is the reason. Video processing in the browser went from "compile ffmpeg to WASM" to "call the platform."


The stack

Everything is permissively licensed and self-hosted — no CDN dependencies, no third-party calls:

  • Video/audio: WebCodecs · MP4Box.js · mp4-muxer · lamejs
  • PDF: pdf-lib · pdf.js
  • Images: native canvas · heic2any
  • ML: ONNX Runtime Web (U²-Net) · Tesseract.js

The whole thing is 208 tools with no build step, no backend, and no accounts. If you want to poke at the implementation, it's at toolpantry.app — and the raw measurements behind the numbers above are written up with methodology at compression-benchmarks and local-processing-benchmarks.


Which of these have you hit? The constructor.name one feels like it should be common knowledge and somehow isn't. And I'd genuinely like to know if anyone has a case where ffmpeg.wasm was still the right call — I couldn't find one that fit inside a 25 MiB limit, but that's a deployment constraint, not a general truth.

Top comments (0)