<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Edem DOGBE</title>
    <description>The latest articles on DEV Community by Edem DOGBE (@edem_dogbe_556b626125bbf3).</description>
    <link>https://dev.to/edem_dogbe_556b626125bbf3</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4105245%2Fbe3bb666-fe42-4e27-8e5a-67f47dbb508a.png</url>
      <title>DEV Community: Edem DOGBE</title>
      <link>https://dev.to/edem_dogbe_556b626125bbf3</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edem_dogbe_556b626125bbf3"/>
    <language>en</language>
    <item>
      <title>Does That Online PDF Tool Upload Your File? A Network Test You Can Run Yourself</title>
      <dc:creator>Edem DOGBE</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:43:03 +0000</pubDate>
      <link>https://dev.to/edem_dogbe_556b626125bbf3/does-that-online-pdf-tool-upload-your-file-a-network-test-you-can-run-yourself-3iid</link>
      <guid>https://dev.to/edem_dogbe_556b626125bbf3/does-that-online-pdf-tool-upload-your-file-a-network-test-you-can-run-yourself-3iid</guid>
      <description>&lt;p&gt;Most online PDF tools tell you the same thing: your files are deleted after an hour. That sentence answers a question nobody asked. A server can only delete a file it has received, so the real question is whether the file left your machine at all, and when.&lt;/p&gt;

&lt;p&gt;You don't need to trust anyone's answer to that. The browser already records every request a page makes. With the developer tools open and a test file, you can see an upload happen, measure it, and find out which server received it. Here is the method we used for a &lt;a href="https://vellumpdf.ch/en/study" rel="noopener noreferrer"&gt;network measurement of popular PDF tools&lt;/a&gt;, written so you can repeat it on any site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prepare a file you can afford to leak
&lt;/h2&gt;

&lt;p&gt;Never test with a real document. Generate a PDF with obviously fake content ("TEST DOCUMENT", a made-up client, a made-up amount) and note its exact size in bytes. Ours was eight pages. The size matters more than the content: you'll recognise the upload because a request body roughly that large shows up in the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up the Network panel
&lt;/h2&gt;

&lt;p&gt;In Chrome or Edge, open DevTools (F12) and go to the Network tab. Three settings change what you see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preserve log.&lt;/strong&gt; Without it, the log is wiped when the page navigates, and some tools redirect to a result page right after upload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable cache.&lt;/strong&gt; Optional, but it makes the page download its code for real, so you can tell code loading apart from everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear the log&lt;/strong&gt; just before you drop the file. Everything that appears afterwards happened because of your file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Firefox has the same thing: Network Monitor, with "Persist Logs" in the settings menu.&lt;/p&gt;

&lt;p&gt;Now drop the test file into the tool and watch. Don't click anything else yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an upload looks like
&lt;/h2&gt;

&lt;p&gt;An upload is a request that carries a body. In practice that means a &lt;code&gt;POST&lt;/code&gt; or &lt;code&gt;PUT&lt;/code&gt;, usually &lt;code&gt;multipart/form-data&lt;/code&gt;, and a request body about the size of your file.&lt;/p&gt;

&lt;p&gt;Type &lt;code&gt;method:POST&lt;/code&gt; in the filter box to hide everything else. For each remaining request, open it and check two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;Headers&lt;/strong&gt; tab, under &lt;em&gt;Request Headers&lt;/em&gt;: &lt;code&gt;Content-Length&lt;/code&gt; and &lt;code&gt;Content-Type&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;Payload&lt;/strong&gt; tab, which shows the form fields being sent, usually including the file name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Careful with the &lt;em&gt;Size&lt;/em&gt; column in the main table. It shows what came &lt;strong&gt;back&lt;/strong&gt;, not what went out. A 2 MB upload that gets a short JSON reply shows a few hundred bytes there. Request size is only visible inside the request.&lt;/p&gt;

&lt;p&gt;Then look at the timing. On 29 August 2026 we ran this test on the compression tool of four popular services. Three of them (iLovePDF, PDF2Go and PDF24 Tools) sent the file &lt;strong&gt;as soon as it was dropped&lt;/strong&gt;, before any "Compress" button was clicked. One of them also fired a request to Microsoft Clarity, a session-analytics service, during the operation. For the fourth, Smallpdf, our automated harness couldn't trigger the native file picker, so we recorded it as &lt;em&gt;not measured&lt;/em&gt; instead of guessing. Infrastructure changes, so these results hold for that date only.&lt;/p&gt;

&lt;p&gt;None of this is hidden or illegal. It is how a server-side tool works: the server is the engine, so the file has to travel. The test simply lets you see it instead of inferring it from a privacy page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find out where it went
&lt;/h2&gt;

&lt;p&gt;The request URL gives you a hostname, such as &lt;code&gt;api110.ilovepdf.com&lt;/code&gt;. Two commands turn that into a location:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short api110.ilovepdf.com
whois 57.129.84.252 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'org-name|orgname|country|netname'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first resolves the host to an IP address, the second asks the regional registry who holds that address block. In our run the uploads ended up with OVH and Hetzner, both in Germany. Two caveats: a CDN or load balancer in front of the real server will show the CDN's address, and the IP you get can depend on where you resolve from. Write down the date and your location next to the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not an upload
&lt;/h2&gt;

&lt;p&gt;A page that processes files locally still makes requests. Learning to read them is what keeps this test honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code downloads.&lt;/strong&gt; A browser-only tool has to fetch its engine before it can run. In VellumPDF, pdf-lib is loaded with a dynamic &lt;code&gt;import()&lt;/code&gt; the first time it's needed rather than on page load, and heavier engines, such as qpdf compiled to WebAssembly, arrive the same way when a tool calls for them. With &lt;em&gt;Disable cache&lt;/em&gt; on, you'll see &lt;code&gt;.js&lt;/code&gt; and &lt;code&gt;.wasm&lt;/code&gt; files arrive after the drop. They are &lt;code&gt;GET&lt;/code&gt; requests with no request body. Data comes in; nothing about your file goes out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics.&lt;/strong&gt; Most sites send page views somewhere. Check what the body contains. Our own site, once a tool has been used, sends one usage event carrying the tool's identifier and nothing else: no file name, size or content. It deliberately sends it when you leave the page, not during processing. A strict test should still show it to you: navigate away with &lt;em&gt;Preserve log&lt;/em&gt; on and you'll see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model downloads.&lt;/strong&gt; Some local features need large data files, such as OCR language models. Those are downloads too, and a well-behaved page tells you before it fetches them.&lt;/p&gt;

&lt;p&gt;The rule: anything with a request body during processing deserves a look. Anything without one is the page receiving, not sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the page's own rules
&lt;/h2&gt;

&lt;p&gt;One response header tells you which servers a page is allowed to contact at all. Open the main document request and find &lt;code&gt;Content-Security-Policy&lt;/code&gt;, then the &lt;code&gt;connect-src&lt;/code&gt; directive. Ours reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connect-src 'self' https://huggingface.co https://*.huggingface.co https://*.hf.co https://raw.githubusercontent.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means scripts on the page can only &lt;code&gt;fetch&lt;/code&gt; its own origin and the hosts that serve models. It's a strong hint, not proof: a CSP doesn't stop a page from uploading to its &lt;em&gt;own&lt;/em&gt; origin, and a site can change its header tomorrow. Combine it with the network log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind spots of the method
&lt;/h2&gt;

&lt;p&gt;The Network panel is good, not perfect. Know where it can miss things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chunked uploads.&lt;/strong&gt; A large file may be split into many requests. Add up the request bodies instead of looking for one large one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deferred sends.&lt;/strong&gt; A page can queue data and send it with &lt;code&gt;navigator.sendBeacon&lt;/code&gt; or &lt;code&gt;fetch(..., { keepalive: true })&lt;/code&gt; when you close the tab. Keep &lt;em&gt;Preserve log&lt;/em&gt; on and navigate away before you conclude anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSockets.&lt;/strong&gt; Frames are under the request's &lt;em&gt;Messages&lt;/em&gt; tab, not in the request list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC.&lt;/strong&gt; Data channels don't appear in the Network panel at all. Chrome shows them in &lt;code&gt;chrome://webrtc-internals&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native file pickers and extensions.&lt;/strong&gt; Automated harnesses struggle with some upload widgets, which is why one service in our table is marked &lt;em&gt;not measured&lt;/em&gt;. A manual run with DevTools open avoids that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The offline check, and its limit
&lt;/h2&gt;

&lt;p&gt;A quicker test: load the tool, process a file once, then turn off your network and process another. If it works offline, that operation doesn't need a server. That's a clean positive result.&lt;/p&gt;

&lt;p&gt;A failure proves less. The tool might simply not have downloaded its engine yet, because lazy-loaded code can't arrive without a connection. Run the operation once online first so the code is loaded, then cut the connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring from inside the page
&lt;/h2&gt;

&lt;p&gt;We wanted users to get this information without opening DevTools, so the result panel of &lt;a href="https://vellumpdf.ch/en/tools/compress-pdf" rel="noopener noreferrer"&gt;our compress tool&lt;/a&gt;, shared by the other tools, reports how many bytes the page sent since the file was dropped. The Performance API won't tell you that: &lt;code&gt;PerformanceResourceTiming&lt;/code&gt; has &lt;code&gt;transferSize&lt;/code&gt; and &lt;code&gt;encodedBodySize&lt;/code&gt;, but both describe the response. Request bodies are invisible to it.&lt;/p&gt;

&lt;p&gt;So the page wraps the sending functions before anything else runs and measures each body as it goes out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;
  &lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;bodySize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;originalFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bodySize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="c1"&gt;// a stream: size unknown, never report zero&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real version also wraps &lt;code&gt;XMLHttpRequest.prototype.send&lt;/code&gt; and &lt;code&gt;navigator.sendBeacon&lt;/code&gt;, handles &lt;code&gt;FormData&lt;/code&gt; and &lt;code&gt;Request&lt;/code&gt; objects, and uses the resource timeline to count loads from other origins.&lt;/p&gt;

&lt;p&gt;It has limits we state on the &lt;a href="https://vellumpdf.ch/en/privacy" rel="noopener noreferrer"&gt;privacy page&lt;/a&gt;. Requests made inside a Web Worker go through the worker's own &lt;code&gt;fetch&lt;/code&gt;, so the window's wrapper never sees them. In our case those are engine downloads from our own domain, which DevTools does show. The bigger limit is a matter of principle: a page reporting on itself is a self-report. Code that wanted to cheat could bypass its own wrapper. The counter is a convenience; the Network panel is the evidence. That's the reason to learn the two-minute test above rather than take anyone's badge, including ours, at face value.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>security</category>
      <category>pdf</category>
    </item>
    <item>
      <title>The Black Rectangle That Doesn't Redact</title>
      <dc:creator>Edem DOGBE</dc:creator>
      <pubDate>Wed, 23 Sep 2026 16:45:28 +0000</pubDate>
      <link>https://dev.to/edem_dogbe_556b626125bbf3/the-black-rectangle-that-doesnt-redact-1d1c</link>
      <guid>https://dev.to/edem_dogbe_556b626125bbf3/the-black-rectangle-that-doesnt-redact-1d1c</guid>
      <description>&lt;p&gt;Documents keep going public with their secrets still inside. The pattern is always the same: someone draws black boxes over the sensitive lines, exports the file, and a reader selects the text under the boxes and pastes it into an email.&lt;/p&gt;

&lt;p&gt;The mistake keeps happening because the result looks correct. A black rectangle is black. Nothing on the screen suggests that the words underneath are still there. Here is why they are, how to check your own files in a minute, and what deleting text from a PDF actually takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a PDF page really is
&lt;/h2&gt;

&lt;p&gt;A PDF page is not a picture with text on it. It is a small program, a content stream, that a viewer runs to paint the page. Text is drawn by one set of operators and shapes by another. Here is a page I generated with pdf-lib: one line of text, then a black rectangle over part of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Salary: CHF 142000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawRectangle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decompressed content stream comes out as this, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight postscript"&gt;&lt;code&gt;&lt;span class="nf"&gt;BT&lt;/span&gt;
&lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;rg&lt;/span&gt;
&lt;span class="nv"&gt;/Helvetica-7098480789&lt;/span&gt; &lt;span class="mf"&gt;16&lt;/span&gt; &lt;span class="nf"&gt;Tf&lt;/span&gt;
&lt;span class="mf"&gt;1&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;1&lt;/span&gt; &lt;span class="mf"&gt;40&lt;/span&gt; &lt;span class="mf"&gt;100&lt;/span&gt; &lt;span class="nf"&gt;Tm&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nf"&gt;53616C6172793A2043484620313432303030&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Tj&lt;/span&gt;
&lt;span class="nf"&gt;ET&lt;/span&gt;
&lt;span class="nf"&gt;q&lt;/span&gt;
&lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;rg&lt;/span&gt;
&lt;span class="mf"&gt;1&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;1&lt;/span&gt; &lt;span class="mf"&gt;100&lt;/span&gt; &lt;span class="mf"&gt;92&lt;/span&gt; &lt;span class="nf"&gt;cm&lt;/span&gt;
&lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;m&lt;/span&gt; &lt;span class="nf"&gt;...&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;
&lt;span class="nf"&gt;Q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first block sets a font, positions the cursor and shows the string. The hex in the &lt;code&gt;Tj&lt;/code&gt; line is the text; it just isn't stored as readable characters. The second block fills a path with black. The viewer paints them in order, so the rectangle lands on top of the text and the eye sees a bar.&lt;/p&gt;

&lt;p&gt;The text was never removed. A viewer that extracts text doesn't care about paint order, only about the &lt;code&gt;Tj&lt;/code&gt; operators, so it reads the string straight through the bar. I ran pdf.js on that file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pdfjs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTextContent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;// [ 'Salary: CHF 142000' ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rectangle tool or a "black highlight" in a PDF editor typically produces exactly this: one more paint operation, with the content underneath untouched. Some editors have a real redaction command that removes the text. If you're not sure which one you used, test the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own file in one minute
&lt;/h2&gt;

&lt;p&gt;You don't need special software:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the exported PDF, select all (&lt;code&gt;Ctrl/Cmd+A&lt;/code&gt;) and paste into a text editor. Any word from under a box is a leak.&lt;/li&gt;
&lt;li&gt;Or search for a word you covered with the viewer's find function. A hit on a blacked-out page is a leak.&lt;/li&gt;
&lt;li&gt;Or run the script above, or a text extractor such as poppler's &lt;code&gt;pdftotext&lt;/code&gt;, and read the output.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do this on the file you are about to send, not on the one you edited. Also look beyond the page text. A PDF can carry the same words in metadata, bookmarks, form fields, annotations and attachments, and a file saved incrementally can keep earlier versions of a page in its own bytes. Covering the visible text fixes none of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the boxes automatically
&lt;/h2&gt;

&lt;p&gt;We build &lt;a href="https://vellumpdf.ch/en" rel="noopener noreferrer"&gt;Vellum&lt;/a&gt;, a set of free browser-based PDF tools, and one of them is a checker for exactly this. The way it works shows how the mistake looks from the inside. It never has to guess where text is hidden. It walks the page's operator list from pdf.js and looks for filled paths.&lt;/p&gt;

&lt;p&gt;It keeps a running transformation matrix through every save, restore and transform operator, plus the last fill colour set. When it meets a fill operator with a dark colour (relative luminance under 0.45), it records the path's bounding box in page coordinates. It also reads annotations of the &lt;code&gt;Square&lt;/code&gt;, &lt;code&gt;Redact&lt;/code&gt; and &lt;code&gt;Highlight&lt;/code&gt; kinds that have a dark interior colour.&lt;/p&gt;

&lt;p&gt;Then come the false-positive filters, which matter more than the detection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A box must be at least 6 points wide and 3 points high, and cover no more than half the page. Thin rules and full-page backgrounds are not redactions.&lt;/li&gt;
&lt;li&gt;The page is rendered at 72 dpi, and the box only counts if at most 2% of its pixels are light. A dark title bar with white text on it is a fill too, but the text is visible, so nothing is hidden and nothing should be reported.&lt;/li&gt;
&lt;li&gt;Only then are the text items compared with the box, and a fragment counts as covered when at least 55% of its area falls inside.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last filter shows where the design comes from. A checker that reports "leak" on every dark rectangle is worse than none, because people stop believing it. So it renders the page and looks at the pixels before saying anything.&lt;/p&gt;

&lt;p&gt;It analyses at most 40 pages per document, since the render check is the expensive step, and it reports how many pages it examined. The whole thing runs in the browser tab. The &lt;a href="https://vellumpdf.ch/en/black-box" rel="noopener noreferrer"&gt;black-box page&lt;/a&gt; runs it if you want to try a document without installing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What removing the text takes
&lt;/h2&gt;

&lt;p&gt;Two approaches work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edit the content stream.&lt;/strong&gt; Find the &lt;code&gt;Tj&lt;/code&gt; operators under the region and rewrite or delete them, leaving the layout intact. It is the cleanest result: the document stays searchable and the file stays small. It is also the hardest to get right. Text may be split into fragments, use custom encodings, or come from a reused form object, and the covered characters may sit in the middle of a fragment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flatten the page.&lt;/strong&gt; Render each page to an image, paint the black boxes onto the pixels, and build a new PDF from the images. The words under the box stop existing, because the only thing left is a picture of the page with a black area on it.&lt;/p&gt;

&lt;p&gt;Our redaction tool takes the second route, and the code is short. In outline (my paraphrase, not the file verbatim):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// pdf.js renders the page to a canvas at 300 dpi&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// black boxes are painted straight onto the pixels&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;boxesOnPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// the output document is built from scratch: one image per page&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;pageWidthPt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageHeightPt&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canvasToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageWidthPt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageHeightPt&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in the real implementation are worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The output starts from an empty document.&lt;/strong&gt; Nothing is copied from the source PDF, so its objects cannot come along by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG, not JPEG.&lt;/strong&gt; JPEG puts a halo of compression noise around every black letter on white, which is visible on text. The code falls back to JPEG at quality 0.95 only when a page's PNG exceeds 4 MB, which in practice means photo-heavy pages. The longest side is also capped at 6,000 pixels so the browser can allocate the canvas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Padding around detected text.&lt;/strong&gt; When boxes come from pattern matching (email addresses, phone numbers, IBANs), the box grows by 35% of the font size horizontally and 15% vertically. A box that misses the edge of a glyph by a pixel leaves a readable sliver of a letter, and a sliver of an "@" is still an "@". Slightly too much black costs nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The price of flattening is that the result is only an image. You lose text selection and search, and screen readers get nothing to read. The file is often larger too. Before you apply, the tool warns that the output becomes an image with no selectable text. Keep the original for the version you still want to edit, and send the flattened copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern search has limits too
&lt;/h2&gt;

&lt;p&gt;Flattening solves the destruction. Choosing what to cover is a separate problem, and it needs the same honesty. The pattern detectors run over text lines rebuilt from pdf.js text fragments, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an IBAN is accepted only if the mod-97 checksum passes, and a Swiss social security number only if its EAN-13 check digit does, which keeps random digit runs from becoming false positives;&lt;/li&gt;
&lt;li&gt;a pattern split across two lines is not found, because matching works line by line;&lt;/li&gt;
&lt;li&gt;a page that is a scan with no text layer has nothing to match, so the tool warns that some pages look like scans and points you to OCR. A proposal that silently skipped them would look like a clean result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever proposals a detector makes, someone has to look at the page before applying it. In the &lt;a href="https://vellumpdf.ch/en/tools/redact-pdf" rel="noopener noreferrer"&gt;redaction tool&lt;/a&gt;, matches are proposed on the preview and nothing is applied until you press the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit worth keeping
&lt;/h2&gt;

&lt;p&gt;Never trust a redaction because it looks like one. Extract the text from the file you are about to send and search it for the words you meant to hide.&lt;/p&gt;

</description>
      <category>security</category>
      <category>privacy</category>
      <category>javascript</category>
      <category>pdf</category>
    </item>
    <item>
      <title>How We Built Browser-Only PDF Tools with WASM</title>
      <dc:creator>Edem DOGBE</dc:creator>
      <pubDate>Wed, 02 Sep 2026 01:44:03 +0000</pubDate>
      <link>https://dev.to/edem_dogbe_556b626125bbf3/how-we-built-browser-only-pdf-tools-with-wasm-569f</link>
      <guid>https://dev.to/edem_dogbe_556b626125bbf3/how-we-built-browser-only-pdf-tools-with-wasm-569f</guid>
      <description>&lt;p&gt;Every online PDF tool asks you to do the same thing before it does anything useful: upload your file. Merge two PDFs, compress a scan, redact a page — step one is always the same button, and it always sends your document to someone else's server first.&lt;/p&gt;

&lt;p&gt;For a lot of PDFs that's a fine deal. For a signed contract, a payslip, or a medical report, it is exactly the wrong one. Those files carry information that is regulated in most jurisdictions, and "we delete it afterward" is a promise about timing, not about whether the upload happened. We build &lt;a href="https://vellumpdf.ch/en" rel="noopener noreferrer"&gt;Vellum&lt;/a&gt;, a set of free PDF tools, on the opposite premise: the file never leaves the device, because there's nowhere for it to go.&lt;/p&gt;

&lt;p&gt;This post is about how that's actually implemented — not as a policy statement, but as an architecture that makes the upload structurally impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Vellum is a static site. No backend, no API, nothing listening for a file. Everything that used to be "send to server, run a tool, send back" now happens as WebAssembly and JavaScript in the tab.&lt;/p&gt;

&lt;p&gt;Three engines carry the actual PDF work, each loaded lazily so a user opening the "rotate pages" tool doesn't pay for OCR they'll never touch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pdf.js&lt;/strong&gt; (&lt;code&gt;pdfjs-dist&lt;/code&gt;) renders pages to canvas and extracts text/structure. It's Mozilla's own PDF engine, already shipped in every Firefox and Chrome tab — we just load it ourselves instead of trusting the browser's built-in viewer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdf-lib&lt;/strong&gt; manipulates PDF structure directly: merging, splitting, page operations, metadata, form filling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tesseract&lt;/strong&gt;, compiled to WebAssembly via &lt;code&gt;tesseract.js&lt;/code&gt;, does OCR. The worker, the WASM core, and every language model are self-hosted under &lt;code&gt;/tesseract/&lt;/code&gt; — nothing is fetched from a CDN:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/ocr.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createOcrWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OcrLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OcrProgress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;workerPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TESS_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;worker.min.js`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;corePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TESS_BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;langPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TESS_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;lang`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cacheMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last point matters more than it looks: most tesseract.js examples pull the core and language data from a public CDN. Doing that would mean every OCR run makes an outbound request to a third party — a small crack in a promise that's supposed to be structural, not best-effort.&lt;/p&gt;

&lt;p&gt;Encryption, decryption, and file repair go through &lt;strong&gt;qpdf&lt;/strong&gt;, also compiled to WASM, loaded from &lt;code&gt;/qpdf/&lt;/code&gt; with a bundler-ignored dynamic import (Emscripten's loader relies on global side effects that Vite/Rollup would otherwise break):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/qpdf.ts&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadQpdfInit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;QpdfInit&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;initPromise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;initPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* @vite-ignore */&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;QPDF_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;qpdf.mjs`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;QpdfInit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;initPromise&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fresh qpdf instance is spun up per operation — the Emscripten runtime isn't reusable after &lt;code&gt;callMain&lt;/code&gt; — but the &lt;code&gt;.wasm&lt;/code&gt; binary itself sits in the browser's HTTP cache, so repeat use doesn't re-download it.&lt;/p&gt;

&lt;p&gt;The part we'd rather not have to say out loud, but which is the whole point: the promise isn't enforced by a privacy policy page. It's enforced by the Content-Security-Policy header, which is the same for every response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;connect-src 'self'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single directive means the browser will refuse any &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;XHR&lt;/code&gt;, &lt;code&gt;WebSocket&lt;/code&gt;, or resource load to a third-party origin — not "we chose not to," but "the browser won't let the page do it even if the code tried." You can verify this yourself with the Network tab open during any operation; that's the whole audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard parts
&lt;/h2&gt;

&lt;p&gt;None of this is free. A few problems only show up once you actually try to do PDF work client-side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redaction that's real, not cosmetic.&lt;/strong&gt; A black rectangle drawn over text in a PDF viewer is famously not redaction — the text is still there, selectable and copy-pasteable underneath. Vellum's redact tool rasterizes each page to an image, paints the redaction boxes directly onto the pixels, and rebuilds the PDF from the images. The original text object simply no longer exists in the output file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/redact.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rendu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rasterisePage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;retoucher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageIndex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tradeoff is honest and unavoidable: the output has no selectable text anymore. That's the price of redaction you can actually trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF/A conformance, proven rather than claimed.&lt;/strong&gt; PDF/A is the ISO standard for long-term archival, and a lot of tools claim to produce it without anything checking that claim. Vellum has two modes. The "faithful" mode preserves selectable text and improves what it can (removes embedded JavaScript, adds an sRGB output intent, flags any non-embedded fonts) but can't guarantee conformance, because that depends on the input document. The "conformant" mode rasterizes every page into an opaque image and reconstructs the file from scratch with no fonts and no dynamic content at all — which is the only mode whose ISO 19005-2 conformance is actually verified: a Node harness (&lt;code&gt;scripts/validate-pdfa.mjs&lt;/code&gt;) builds a corpus of deliberately awkward PDFs (unembedded standard fonts, transparency, AcroForms, scanned images) and runs every output through veraPDF, the reference validator. It fails the build if the conformant mode doesn't pass on all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory and loading size.&lt;/strong&gt; Everything runs in the tab's own memory, so the honest limit is "however much RAM the browser gives this page" — a typical office document is comfortable, a two-gigabyte scanned atlas is not. On the loading side, qpdf and Tesseract's WASM binaries are sizeable, so the service worker treats them differently from the app shell: the shell (JS/CSS/HTML) is precached on install, while the WASM engines are cached on first use with a &lt;code&gt;CacheFirst&lt;/code&gt; strategy and a 180-day expiration — you pay the download once, not the app-shell cost of bundling megabytes nobody asked for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline without surprising anyone.&lt;/strong&gt; The PWA can install and keep working with no connection at all, but it never reloads itself out from under a running conversion. &lt;code&gt;registerType: 'prompt'&lt;/code&gt; means an available update sits quietly until the user dismisses a non-blocking toast — worth calling out because "autoUpdate" silently swapping the running app mid-task is exactly the failure mode a tool meant for offline, in-progress work can't afford.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we measured
&lt;/h2&gt;

&lt;p&gt;Claims about competitors are cheap, so we measured instead of asserting. On 2026-08-29 we dropped the same eight-page test PDF into the compression tool of the most-used online PDF services, with the browser's Network tab logging every outgoing request, then resolved each destination host to an IP and its hosting provider.&lt;/p&gt;

&lt;p&gt;iLovePDF uploaded the file to &lt;code&gt;api110.ilovepdf.com&lt;/code&gt; (OVH, Germany) immediately on drop — before any click on "Compress." PDF2Go uploaded to a Hetzner server in Nuremberg and fired a simultaneous request to Microsoft Clarity for session behaviour analytics. PDF24 Tools uploaded to Hetzner as well, while displaying a "Secure" badge on screen. Smallpdf is marked "not measured" — its native file picker defeated our automated method, and we'd rather leave a gap than guess. Vellum, running the same compression on the same file: zero network requests.&lt;/p&gt;

&lt;p&gt;The full protocol and data are at &lt;a href="https://vellumpdf.ch/en/etude" rel="noopener noreferrer"&gt;vellumpdf.ch/en/etude&lt;/a&gt;. None of the measured services lied about eventually deleting the file — the point the data makes is that deletion happens after the upload, not instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The business model
&lt;/h2&gt;

&lt;p&gt;The tools are free with no quota, because there's no server cost to ration against. Revenue comes from a separate B2B tier, Vellum Cabinet, aimed at law and accounting firms bound by professional secrecy: a signed processing attestation for their compliance records plus an on-premise build, sold alongside — not instead of — the free public tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;The claim in this post is falsifiable in about ten seconds: open any tool at &lt;a href="https://vellumpdf.ch/en" rel="noopener noreferrer"&gt;vellumpdf.ch/en&lt;/a&gt;, switch off Wi-Fi, and process a file. It works, because nothing needed to leave in the first place — the same test you can run against any competing service, where it won't.&lt;/p&gt;




&lt;p&gt;Edem Dogbe, DOGBE MULTISYSTEM, Switzerland&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>software</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
