<?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%2Fe820c3e6-1440-4067-b49e-ac27ff23cdab.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>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>
