<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tool Pantry]]></title><description><![CDATA[Engineering notes from building Tool Pantry — 208 free browser tools that process files entirely on your device. WebCodecs, pdf-lib, and the bugs in between.]]></description><link>https://toolpantry.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6acf795675df617aae07d505/2c098ed0-388d-4753-bf9b-c3374100977e.png</url><title>Tool Pantry</title><link>https://toolpantry.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 05:24:51 GMT</lastBuildDate><atom:link href="https://toolpantry.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[208 file tools that never upload your files — and the 7 bugs that taught us how]]></title><description><![CDATA[ffmpeg.wasm didn't ship, pdf-lib lied about deleting, and one bug was our own measuring tool. Real numbers from a client-side file toolkit.]]></description><link>https://toolpantry.hashnode.dev/208-file-tools-that-never-upload-your-files-and-the-7-bugs-that-taught-us-how</link><guid isPermaLink="true">https://toolpantry.hashnode.dev/208-file-tools-that-never-upload-your-files-and-the-7-bugs-that-taught-us-how</guid><category><![CDATA[webdev]]></category><category><![CDATA[privacy]]></category><category><![CDATA[Programming Tips]]></category><category><![CDATA[frontend]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[shenghua wu]]></dc:creator><pubDate>Wed, 14 Oct 2026 13:07:39 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>Disclosure:</strong> 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.</p>
</blockquote>
<p>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.</p>
<p>We built <a href="https://toolpantry.app">Tool Pantry</a> on the opposite premise: <strong>208 tools, zero uploads</strong>. PDF, images, video, audio, text. Nothing ever leaves the device — not by policy, by architecture.</p>
<p>That constraint turned out to be a much better engineering problem than we expected. Here are the seven things that actually bit us.</p>
<hr />
<h2>1. The 25 MiB wall — why we abandoned ffmpeg.wasm</h2>
<p>Video compression was the first "obviously we'll use the obvious library" decision. <code>ffmpeg.wasm</code> is the standard answer for browser video.</p>
<p>It died at deploy time.</p>
<p><code>ffmpeg-core.wasm</code> is <strong>30.7 MiB</strong>. Cloudflare Workers caps a single asset at <strong>25 MiB</strong>. 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.</p>
<p>So we rebuilt the pipeline on native browser primitives:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Library</th>
<th>Size</th>
</tr>
</thead>
<tbody><tr>
<td>Demux</td>
<td>MP4Box.js</td>
<td>~200 KB</td>
</tr>
<tr>
<td>Decode / scale / encode</td>
<td><strong>WebCodecs</strong> (<code>VideoDecoder</code>, <code>VideoEncoder</code>)</td>
<td>built in</td>
</tr>
<tr>
<td>Mux</td>
<td>mp4-muxer</td>
<td>~50 KB</td>
</tr>
</tbody></table>
<p>Total: <strong>~390 KB</strong> instead of ~30 MB — a 77× reduction. And it's not just smaller, it's <em>faster</em>, because WebCodecs hits hardware encoders instead of compiling x264 to WASM.</p>
<p>Verified output: 1280×720 6s source → 854×480 H.264 Baseline + AAC, 1.81 MB, confirmed with <code>ffprobe</code> picking apart the actual container.</p>
<p><strong>Lesson:</strong> 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.</p>
<hr />
<h2>2. <code>pdf-lib</code>'s <code>removePage</code> doesn't delete anything</h2>
<p>Deleting a page from a PDF should make the file smaller. Ours got <em>bigger</em>:</p>
<pre><code class="language-plaintext">793 KB  →  remove one page  →  812 KB
</code></pre>
<p><code>removePage()</code> 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.</p>
<p>The fix is a rebuild: create a fresh <code>PDFDocument</code>, <code>copyPages()</code> only the pages you're keeping, and save that.</p>
<pre><code class="language-plaintext">812 KB  →  copyPages rebuild  →  510 KB
</code></pre>
<p>A 37% reduction — the data genuinely gone.</p>
<p>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.</p>
<p><strong>Lesson:</strong> in PDF, "delete" almost always means "unlink." If a user's threat model includes recovering deleted content, unlinking is not enough.</p>
<hr />
<h2>3. <code>constructor.name</code> is a lie in minified builds</h2>
<p>We shipped a PDF form-filler that detects AcroForm fields — text, checkbox, dropdown, radio — and renders the right input for each.</p>
<p>It worked perfectly in development and detected nothing in production.</p>
<p>Root cause: pdf-lib ships minified. In the minified build, <strong>every class's</strong> <code>constructor.name</code> <strong>is</strong> <code>"n"</code>. Our type dispatch (<code>field.constructor.name === 'PDFTextField'</code>) matched nothing.</p>
<p>The fix is <code>instanceof</code> against the exported classes, which survives minification because it compares actual prototype chains:</p>
<pre><code class="language-js">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;
</code></pre>
<p><strong>Lesson:</strong> never dispatch on <code>constructor.name</code> for anything that ships minified — including your own code, if a bundler might rename it. <code>instanceof</code> or an explicit discriminant field, always.</p>
<hr />
<h2>4. MP4 rotation lives in the header, not the pixels</h2>
<p>A phone video shot in portrait is usually stored <strong>landscape</strong>, with a rotation matrix in the <code>tkhd</code> box telling the player "turn this 90°." The pixels never change; the metadata does.</p>
<p>MP4Box.js exposes both, and they disagree:</p>
<ul>
<li><p><code>track.track_width</code> / <code>track_height</code> → the <strong>display</strong> size from <code>tkhd</code></p>
</li>
<li><p><code>videoTrack.video.width</code> / <code>height</code> → the <strong>encoded</strong> size from <code>stsd</code></p>
</li>
</ul>
<p>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.</p>
<p>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 <strong>rewriting one 3×3 matrix in a header</strong>.</p>
<p>We rewrite the <code>tkhd</code> matrix directly. Verified: byte-identical result to re-encoding with <code>transpose=1</code>, pixel difference <strong>0.0</strong>. It finishes in 0.0 seconds on a 6-second clip, because nothing is decoded.</p>
<p>Same trick for muting: stream-copy the video track, drop the audio track. The output video bitstream's MD5 is <strong>identical to the source</strong>. Lossless, verifiable, instant.</p>
<p><strong>Lesson:</strong> before you write the pixel loop, check whether the answer is already a number in a header.</p>
<hr />
<h2>5. The color bug that wasn't — verify your measurement tool first</h2>
<p>This one nearly cost us a working pipeline.</p>
<p>We compared input and output video frames pixel by pixel and got a clean, damning result:</p>
<pre><code class="language-plaintext">output ≈ 0.81 × source + 23
</code></pre>
<p>That formula is the signature of a YUV↔RGB <strong>range mismatch</strong> — the classic full-range (0–255) vs. limited-range (16–235) bug. Textbook. So we started hunting for it: checked the VUI and <code>colr</code> atoms, inspected our <code>VideoFrame</code> color-space options, ran encode round-trips, scanned for time-alignment offsets, generated difference maps.</p>
<p>We found nothing, because there was nothing to find.</p>
<p><code>ffprobe</code> was reading our <strong>untagged</strong> source file and <em>guessing</em> the YUV matrix. It guessed wrong. The "0.81× + 23" was an artifact of the measuring tool, not a defect in our code.</p>
<p>The real test: decode source and output in the same decoder (Chrome) and compare flat regions — delta <strong>≤ 3</strong>, which is just normal lossy-compression noise.</p>
<p>We were one commit away from "fixing" a correct color pipeline and breaking it.</p>
<p><strong>Lesson:</strong> when a measurement gives you a textbook-perfect diagnosis, be suspicious. Confirm the <em>tool</em> before you trust the <em>verdict</em>.</p>
<hr />
<h2>6. <code>SHA256</code> is not <code>SHA-256</code></h2>
<pre><code class="language-js">await crypto.subtle.digest('SHA256', data);
// → NotSupportedError
</code></pre>
<p>WebCrypto wants the hyphenated form: <code>'SHA-256'</code>. The un-hyphenated spelling fails with an error that gives you no hint about the format being the problem.</p>
<p>Our hash tool now maps friendly names to the exact WebCrypto identifiers:</p>
<pre><code class="language-js">const webcryptoNames = {
  sha1: 'SHA-1', sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512'
};
</code></pre>
<p>We checked the output byte-for-byte against Python's <code>hashlib</code> to be sure. It matches.</p>
<hr />
<h2>7. Transparent canvas pixels have garbage color channels</h2>
<p>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.</p>
<p>Bug: it kept hallucinating ink in empty areas.</p>
<p>Cause: in a canvas, a fully transparent pixel can still have color values — and drawing operations often leave them at <code>0,0,0</code> (black). Our threshold ("dark pixels = ink") read the entire transparent region as ink.</p>
<p>Fix: gate on alpha <em>before</em> thresholding.</p>
<pre><code class="language-js">if (alpha === 0) continue;  // never trust the color of an invisible pixel
</code></pre>
<p><strong>Lesson:</strong> transparent pixels are not "empty." Their color channels are whatever the last write left behind. Always check alpha first.</p>
<hr />
<h2>What this actually costs you (the honest section)</h2>
<p>Client-side isn't free. The real trade-offs:</p>
<ul>
<li><p><strong>Download size.</strong> Background removal needs a U²-Net model via ONNX Runtime Web — about <strong>15 MB</strong>, lazy-loaded. OCR needs Tesseract WASM — about <strong>8 MB</strong>. First use of those two tools is a real wait.</p>
</li>
<li><p><strong>Browser floors.</strong> The video tools need <code>WebCodecs</code> — Chrome/Edge, and Safari 17+. Older browsers can have the PDF and image tools, not the video ones.</p>
</li>
<li><p><strong>Threading requires headers.</strong> ONNX Runtime's multithreaded path needs <code>COOP: same-origin</code> + <code>COEP: require-corp</code>. That's fine for a site with no third-party embeds, and it means those two headers are now load-bearing.</p>
</li>
<li><p><strong>And there's a ceiling.</strong> 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.</p>
</li>
</ul>
<p>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."</p>
<hr />
<h2>The stack</h2>
<p>Everything is permissively licensed and self-hosted — no CDN dependencies, no third-party calls:</p>
<ul>
<li><p><strong>Video/audio:</strong> WebCodecs · MP4Box.js · mp4-muxer · lamejs</p>
</li>
<li><p><strong>PDF:</strong> pdf-lib · pdf.js</p>
</li>
<li><p><strong>Images:</strong> native canvas · heic2any</p>
</li>
<li><p><strong>ML:</strong> ONNX Runtime Web (U²-Net) · Tesseract.js</p>
</li>
</ul>
<p>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 <a href="https://toolpantry.app">toolpantry.app</a> — and the raw measurements behind the numbers above are written up with methodology at <a href="https://toolpantry.app/compression-benchmarks">compression-benchmarks</a> and <a href="https://toolpantry.app/local-processing-benchmarks">local-processing-benchmarks</a>.</p>
<hr />
<p><em>Which of these have you hit? The</em> <code>constructor.name</code> <em>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</em> <code>ffmpeg.wasm</code> <em>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.</em></p>
]]></content:encoded></item></channel></rss>