<?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>Shrinking a PDF Without Turning Its Pages Into Pictures</title>
      <dc:creator>Edem DOGBE</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:38:17 +0000</pubDate>
      <link>https://dev.to/edem_dogbe_556b626125bbf3/shrinking-a-pdf-without-turning-its-pages-into-pictures-1eh2</link>
      <guid>https://dev.to/edem_dogbe_556b626125bbf3/shrinking-a-pdf-without-turning-its-pages-into-pictures-1eh2</guid>
      <description>&lt;p&gt;Most browser-side "compress PDF" code does the same thing. It renders every page to a canvas, encodes that canvas as a JPEG, and builds a new PDF with one picture per page. It works on anything, and the file usually gets smaller. It also destroys the document: the text can no longer be selected or searched, vector drawings become blurry pixels, links are gone, and a screen reader finds nothing to read.&lt;/p&gt;

&lt;p&gt;For a scanned contract that is a fair trade. For a report that is heavy only because someone pasted ten phone photos into it, it is the wrong tool. The text and vector graphics weigh almost nothing; the photos are the only thing worth touching.&lt;/p&gt;

&lt;p&gt;This post walks through the second approach as we implemented it in the &lt;a href="https://vellumpdf.ch/en/tools/compress-pdf" rel="noopener noreferrer"&gt;PDF compressor&lt;/a&gt; of VellumPDF, which runs entirely in the browser with pdf-lib and a canvas. Most of the work is not compressing. It is deciding which images you are allowed to compress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images in a PDF are just streams
&lt;/h2&gt;

&lt;p&gt;An image in a PDF is an object, usually shared: a stream whose dictionary says &lt;code&gt;/Subtype /Image&lt;/code&gt;, with its width, height, color space, bits per component and the filter that encodes its bytes. Pages do not contain the image; they reference it by object number. The same logo can appear on every page and exist once in the file.&lt;/p&gt;

&lt;p&gt;That gives you a clean plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Walk every indirect object in the document.&lt;/li&gt;
&lt;li&gt;Keep the raw streams whose dictionary says &lt;code&gt;/Subtype /Image&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decode the pixels, resize them if they are larger than needed, re-encode them as JPEG.&lt;/li&gt;
&lt;li&gt;Put the new bytes back &lt;strong&gt;under the same object number&lt;/strong&gt;, so every page that pointed to the old image now points to the new one without being touched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With pdf-lib, step 1 is &lt;code&gt;doc.context.enumerateIndirectObjects()&lt;/code&gt;, which returns &lt;code&gt;[ref, object]&lt;/code&gt; pairs. Step 4 is less obvious. pdf-lib's &lt;code&gt;JpegEmbedder&lt;/code&gt; has an &lt;code&gt;embedIntoContext(context, ref)&lt;/code&gt; method, and if you pass it an existing &lt;code&gt;ref&lt;/code&gt;, it writes the new image stream into that slot instead of allocating a new one:&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;embedder&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;JpegEmbedder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newJpegBytes&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;embedder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedIntoContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The embedder builds a fresh dictionary for the stream (width, height, color space read from the JPEG itself, &lt;code&gt;/DCTDecode&lt;/code&gt;). Anything the old dictionary carried is lost, which matters for transparency, as we will see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eligibility list is the real work
&lt;/h2&gt;

&lt;p&gt;If you recompress every image you find, some documents will come out with wrong colors, broken transparency or invalid structure. Our eligibility function returns "leave it alone" for all of the following. Each rule exists because of a specific way to break a file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stencil masks (&lt;code&gt;/ImageMask true&lt;/code&gt;).&lt;/strong&gt; These are 1-bit shapes painted with the current fill color, not pictures. Turning one into a JPEG makes no sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An explicit &lt;code&gt;/Decode&lt;/code&gt; array.&lt;/strong&gt; It remaps sample values, sometimes inverting them. Re-encoding the decoded pixels and dropping the array would flip or shift the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;/Mask&lt;/code&gt; entry.&lt;/strong&gt; Either a color-key mask (an array: "treat these colors as transparent") or a stencil stream. JPEG is lossy and shifts colors slightly, so a color key that matched exact values stops matching. And since the new dictionary is built from scratch, the entry would be lost anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A soft mask carrying &lt;code&gt;/Matte&lt;/code&gt;.&lt;/strong&gt; A soft mask (&lt;code&gt;/SMask&lt;/code&gt;) is a grayscale stream that gives an image its alpha channel. When it has a &lt;code&gt;/Matte&lt;/code&gt; entry, the image was pre-blended with a background color, and the specification requires the mask and the image to have the same dimensions. Shrink the image and the file is no longer valid. So these are skipped too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any filter other than &lt;code&gt;DCTDecode&lt;/code&gt; or &lt;code&gt;FlateDecode&lt;/code&gt;.&lt;/strong&gt; JBIG2 and CCITT are bilevel formats that already compress black-and-white scans far better than JPEG ever will. JPX (JPEG 2000) and LZW are rare enough that we left them out rather than ship a decoder for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything other than 8 bits per component&lt;/strong&gt;, for Flate images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flate images with a PNG or TIFF predictor.&lt;/strong&gt; With &lt;code&gt;/DecodeParms &amp;lt;&amp;lt; /Predictor 10..15 &amp;gt;&amp;gt;&lt;/code&gt;, the inflated bytes are not pixels yet; each row starts with a filter byte and stores differences from the row above. You have to undo that before you have an image. We do not, so these images stay as they are. This is a real gap: images that came from PNG files often use predictors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Color spaces other than gray or RGB.&lt;/strong&gt; We accept &lt;code&gt;DeviceGray&lt;/code&gt;, &lt;code&gt;DeviceRGB&lt;/code&gt;, and &lt;code&gt;ICCBased&lt;/code&gt; with 1 or 3 components. Everything else is skipped: CMYK, Indexed, Separation, DeviceN, CalRGB. CMYK JPEGs deserve a special mention. Files written by Adobe software often store them inverted and flag it in an APP14 marker. Decode one in a browser, re-encode it, and you get an RGB image with colors that range from slightly off to photographic negative. Not worth the risk.&lt;/p&gt;

&lt;p&gt;One more rule sits outside the per-image check. Before the main loop, we collect every object that some other image references as its &lt;code&gt;/SMask&lt;/code&gt;. Those streams look exactly like ordinary 8-bit grayscale images, and the main loop would happily recompress them. A soft mask turned into a lossy JPEG gets fuzzy edges and halos, so mask streams are never touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoding, resizing, encoding
&lt;/h2&gt;

&lt;p&gt;Once an image passes, the pixels come from one of two paths.&lt;/p&gt;

&lt;p&gt;For a &lt;code&gt;DCTDecode&lt;/code&gt; stream, the bytes are a JPEG file. The browser decodes it: &lt;code&gt;createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }))&lt;/code&gt;, draw it on a canvas, read back &lt;code&gt;getImageData&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a &lt;code&gt;FlateDecode&lt;/code&gt; stream, pdf-lib inflates it with &lt;code&gt;decodePDFRawStream(stream).decode()&lt;/code&gt;, and we expand the gray or RGB samples to RGBA ourselves. If the inflated buffer is shorter than &lt;code&gt;width × height × components&lt;/code&gt;, the stream is truncated or we misread it, and we bail out on that image.&lt;/p&gt;

&lt;p&gt;Then: optional grayscale conversion, a resize so that the longest side does not exceed a limit, and &lt;code&gt;canvas.toBlob(callback, 'image/jpeg', quality)&lt;/code&gt;. Our default setting caps the longest side at 2000 pixels and uses quality 0.75.&lt;/p&gt;

&lt;p&gt;The step that matters most comes next:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newJpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="c1"&gt;// keep the original&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An image that is already a well-compressed JPEG, or a small flat-color Flate image, can come out &lt;em&gt;larger&lt;/em&gt; after re-encoding. We only swap when the result is lighter. Without this check, "compression" makes some files grow.&lt;/p&gt;

&lt;p&gt;If the original image had a soft mask, we put the reference back on the new dictionary after embedding:&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;smask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PDFName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SMask&lt;/span&gt;&lt;span class="dl"&gt;'&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;embedder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedIntoContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&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;smask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PDFStream&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PDFName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SMask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;smask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A decode or encode failure on a single image (a damaged progressive JPEG, for instance) is caught and skipped. One bad image should not fail the whole document.&lt;/p&gt;

&lt;p&gt;Finally the document is saved with &lt;code&gt;doc.save({ useObjectStreams: true })&lt;/code&gt;, which packs the small non-stream objects into compressed object streams. On its own, that re-save is our lossless "Light" level, which can save a little on files written by tools that did not use object streams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grayscale trap
&lt;/h2&gt;

&lt;p&gt;Our "grayscale images" option has a limit worth knowing. The canvas JPEG encoder only writes three-component JPEGs. Set R = G = B on every pixel and the image looks gray, but the file keeps three channels, and pdf-lib tags it &lt;code&gt;DeviceRGB&lt;/code&gt;. You lose the color without saving the bytes a one-channel JPEG would save. Real single-channel output means shipping your own JPEG encoder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hitting a target size without compounding damage
&lt;/h2&gt;

&lt;p&gt;People rarely want "compress at level 2". They want "under 2 MB, because the upload form says so". Our target-size mode tries settings in order: first image recompression with progressively stricter limits (2000 px at 0.75, then 1600 px at 0.6, 1200 px at 0.45, 900 px at 0.3), and only after that, as a last resort, full-page rasterization at decreasing scales.&lt;/p&gt;

&lt;p&gt;Two details made this behave well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every attempt starts from the original bytes.&lt;/strong&gt; It is tempting to feed attempt N's output into attempt N+1. That re-encodes JPEGs that were already re-encoded, and each pass adds artifacts on top of the previous ones. Reloading the original is slower and gives a cleaner result at the same size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The best result is kept even if the target is missed, and the user is told.&lt;/strong&gt; The interface says plainly whether the target was reached, and shows a separate warning when reaching it required converting pages to images, because at that point the text is no longer selectable. If the output is heavier than the input, a "Keep the original" button appears. Silently delivering a worse file is the failure mode to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the PDF logic testable
&lt;/h2&gt;

&lt;p&gt;The module that walks the PDF never imports a canvas. The JPEG decoder and encoder are passed in as functions. In the browser they wrap &lt;code&gt;createImageBitmap&lt;/code&gt; and &lt;code&gt;canvas.toBlob&lt;/code&gt;. In a Node test they wrap &lt;code&gt;@napi-rs/canvas&lt;/code&gt;. Everything else (object traversal, eligibility, stream replacement, the size comparison) is plain JavaScript that runs under Node against real PDF files, without a browser.&lt;/p&gt;

&lt;p&gt;That split matters because the eligibility rules are where the bugs hide, and the cases worth checking (a CMYK JPEG, a masked image, a predictor-encoded Flate stream) are much easier to feed to a Node script than to click through in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on a real file
&lt;/h2&gt;

&lt;p&gt;If you want to see the behavior on your own files, the &lt;a href="https://vellumpdf.ch/en/tools/compress-pdf" rel="noopener noreferrer"&gt;compressor&lt;/a&gt; runs locally in the tab, next to the other &lt;a href="https://vellumpdf.ch/en" rel="noopener noreferrer"&gt;free PDF tools on VellumPDF&lt;/a&gt;. Try a document with selectable text on the default level, then on "Maximum", and compare what you can still copy out of each.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <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>
