<?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: Ren</title>
    <description>The latest articles on DEV Community by Ren (@openedfile).</description>
    <link>https://dev.to/openedfile</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%2F4193655%2F1cea253f-5284-4144-951c-86e174d97cee.jpg</url>
      <title>DEV Community: Ren</title>
      <link>https://dev.to/openedfile</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/openedfile"/>
    <language>en</language>
    <item>
      <title>A 5.5 KB DXF file can expand to 100 million lines: budgeting a browser CAD viewer</title>
      <dc:creator>Ren</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:03:19 +0000</pubDate>
      <link>https://dev.to/openedfile/a-55-kb-dxf-file-can-expand-to-100-million-lines-budgeting-a-browser-cad-viewer-43d9</link>
      <guid>https://dev.to/openedfile/a-55-kb-dxf-file-can-expand-to-100-million-lines-budgeting-a-browser-cad-viewer-43d9</guid>
      <description>&lt;p&gt;I run a DXF viewer that works entirely in the browser. DXF is the text format CAD programs use to exchange drawings. The parser (from dxf-viewer; MIT and MPL 2.0) runs in a Web Worker, the drawing is built there and handed to the page, and the page draws it on a canvas. The file never goes to a server.&lt;/p&gt;

&lt;p&gt;The files people open are drawings someone else sent them. A DXF file (the text kind) is just text, so anyone can write any drawing by hand. I wanted a crafted drawing to be unable to freeze or crash the tab. The part that took far longer than I expected was deciding &lt;em&gt;what to count&lt;/em&gt; when setting a limit. This post is about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small files, big drawings
&lt;/h2&gt;

&lt;p&gt;DXF lets you define a group of entities as a &lt;em&gt;block&lt;/em&gt; and place it any number of times with INSERT. An INSERT inside a block nests. A MINSERT places a block as a whole grid in one go.&lt;/p&gt;

&lt;p&gt;Make a block B0 of 10 lines, a block B1 that inserts B0 ten times, B2 that inserts B1 ten times, and so on to B7, then insert B7 once. The file is 5.5 KB. Expanded naively, it is 10^8 lines: one hundred million.&lt;/p&gt;

&lt;p&gt;A few of the samples from my tests (all written from scratch by a script):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Built naively&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blocks of 10 lines nested 7 deep, 10 inserts each&lt;/td&gt;
&lt;td&gt;5.5 KB&lt;/td&gt;
&lt;td&gt;100 million lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A 20,000-character MTEXT in a 100 × 100 array&lt;/td&gt;
&lt;td&gt;21 KB&lt;/td&gt;
&lt;td&gt;200 million characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An MTEXT line of 1,000 colour-changing runs, 14 × 14&lt;/td&gt;
&lt;td&gt;7.5 KB&lt;/td&gt;
&lt;td&gt;196,000 &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; in the print SVG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000 long lines, 40 × 40&lt;/td&gt;
&lt;td&gt;78 KB&lt;/td&gt;
&lt;td&gt;1.6 million lines across the screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A self-intersecting 5,000-vertex star fill, 30 × 30&lt;/td&gt;
&lt;td&gt;115 KB&lt;/td&gt;
&lt;td&gt;900 fills taking about 0.9 s each at full screen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  First attempt: count what is accepted
&lt;/h2&gt;

&lt;p&gt;The builder (in the Worker) had a work budget from the start. It counts the coordinates, fills and text that enter the drawing; when the budget runs out it stops, and the page says "Too many objects: stopped after drawing about N lines, texts and other pieces."&lt;/p&gt;

&lt;p&gt;At first it counted what was &lt;strong&gt;accepted&lt;/strong&gt; into the drawing: the points of lines that would be drawn, the characters of text. A natural design. But every review round found one more path that did work without paying for it, five rounds in a row. Here are the paths, with a sample that hits each:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expanding the nesting itself.&lt;/strong&gt; Nest blocks that contain nothing drawable (only entity types the viewer does not draw) 9 deep. Nothing is accepted, so the budget never moves, while the expansion runs a billion times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hatch boundaries.&lt;/strong&gt; The boundary points of a fill were an intermediate array, not counted as accepted geometry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markup that ends up empty.&lt;/strong&gt; An MTEXT with 60,000 runs that parse to nothing (an underline code, &lt;code&gt;%%u&lt;/code&gt;, wrapped in braces). No characters are accepted, but parsing them costs time and memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refused copies.&lt;/strong&gt; Insert a block as a 1,000 × 1,000 grid at a scale that puts every copy beyond 1e15. Each copy was made, then refused, and the making was never counted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unreadable coordinates and the cache.&lt;/strong&gt; A polyline of 20,000 bulges starting at x = &lt;code&gt;1e999&lt;/code&gt; (which parses to Infinity), inside a block. The cache that builds a block's points once and reuses them was building points even for coordinates that could not be used.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The five were in different places but had the same shape: &lt;strong&gt;the moment work was done and the moment it was paid for were different.&lt;/strong&gt; With "pay on acceptance" or "ask first without paying", a copy made and then refused, an intermediate array, or a cache will always find a way to do work for free.&lt;/p&gt;

&lt;p&gt;So there is now one place that pays, and it pays at the moment of allocation:&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="cm"&gt;/**
 * The only charge: n units for what is about to be allocated, charged now,
 * whatever becomes of it (refused or discarded later, it is not refunded).
 * Past the budget it refuses, and from then on everything stops.
 */&lt;/span&gt;
&lt;span class="nf"&gt;alloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;work&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxWork&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;work&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;Producers whose point count depends on the input (bulges, splines, hatch edges and patterns) count the points first, &lt;code&gt;alloc()&lt;/code&gt; them, then make them. A block's entities are built once (and paid for then); each INSERT's copy is paid for before it is written. If a copy turns out unusable after it was written, it is dropped and the charge stays.&lt;/p&gt;

&lt;p&gt;Entities whose coordinates cannot be read (&lt;code&gt;1e999&lt;/code&gt;, NaN, beyond 1e15, scale 0, absurd angles) are skipped at the entrance without making anything, and counted as "unreadable coordinates".&lt;/p&gt;

&lt;p&gt;Weights follow what is stored: a coordinate pair is one unit; a text is 32 units plus its runs, lines and characters. Looking at an entity or expanding a block is also one unit (that one is for time, not memory, and it closes the first path). The budget is 8 million units. The 100-million-line sample now stops after 2.48 million lines (1.6 s from reading to building, in Node), draws what it has, and says it stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page side: limits on a proxy get routed around
&lt;/h2&gt;

&lt;p&gt;With the builder bounded, I set limits on the page: at most 4,096 draw batches (layer × colour × linetype), a print SVG of at most 64 MB and 200,000 elements, a layer menu of at most 300 rows. Review routed around these too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1,000 long lines, 40 × 40 (78 KB).&lt;/strong&gt; 1.6 million lines fit in the budget, in a single batch. Handed to one canvas &lt;code&gt;stroke()&lt;/code&gt;, they freeze the page. As an experiment, stroking all 1.6 million in one &lt;code&gt;stroke()&lt;/code&gt; on a 1,200 × 800 canvas in Chromium took 6.3 s at a width just under one pixel (0.999); at 1.25 device pixels (the normal width on a 2× screen) it had not finished after 170 s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An MTEXT line of 1,000 colour runs, 14 × 14 (7.5 KB).&lt;/strong&gt; The print SVG is 26 MB with 196,000 &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt;, under both 64 MB and 200,000 elements. Just displaying that SVG in Chromium took the renderer from 81 MB to 1.9 GB.&lt;/p&gt;

&lt;p&gt;Both limits counted the wrong thing. I wanted to bound time, and counted segments. I wanted to bound memory, and counted elements and characters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bound time with time.&lt;/strong&gt; The drawing is cut into chunks with fixed upper limits (1,024 segments of lines, 256 arcs, and so on). A draw strokes chunk after chunk until about 20 ms have passed, then continues on the next animation frame. A zoom, pan or layer toggle drops the draw in progress and starts over. Stroking the same 1.6 million lines 1,024 at a time took at most 24 ms per chunk even at 1.25 px (27.5 s in all).&lt;/p&gt;

&lt;p&gt;The viewer adds two more things. In a batch of very many long lines, lines that coincide to a pixel on screen are drawn once (with this drawing fitted to a 1,200 × 800 view, 960,000 of the 1.6 million). And a heavy drawing (more than 20,000 segments and arcs on the shown layers) is drawn no wider than one pixel, even on a 2× screen. Opened in the viewer, this drawing takes 4–10 s to finish and 16–30 s to redraw after a zoom, with main-thread stalls of at most about 0.2 s (0.14–0.21 s over several runs), and the page stays usable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bound memory with memory.&lt;/strong&gt; The print SVG is built while estimating the renderer's memory, with weights per element kind measured in Chromium:&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;// Measured from the renderer's memory when printing an A3 sheet to PDF (above an empty sheet)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRINT_COST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;char&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_PRINT_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Past 400 MB of estimate it stops and says the drawing is too large to convert for printing. The largest print SVG among the real drawings I have is 13 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Slicing by time still leaks if a step is sized by count
&lt;/h2&gt;

&lt;p&gt;Time slicing was not the end. Fill chunks were cut at "at most 1,024 loops".&lt;/p&gt;

&lt;p&gt;Filling one self-intersecting star of 5,000 vertices (every edge spans the whole shape) across a 1,200 × 800 canvas takes 0.85 s in Chromium with the nonzero rule, which the viewer uses for a single loop (2.1 s with even-odd). A 20,000-vertex star takes 11.7 s with even-odd. Meanwhile 2,000 small circles fill in 4 ms. The number of loops says almost nothing about the cost. The 900 stars of the 30 × 30 grid fit in one step, and in review that step froze the page for over 300 s.&lt;/p&gt;

&lt;p&gt;My working model is that the cost is decided by how many edges cross each row of the screen. Assuming each row's crossings have to be sorted, n crossings cost about n log n. I count that as &lt;em&gt;scan work&lt;/em&gt;, and checked the model against measurements (with even-odd, the 5,000-vertex star is about 30 million units and 2.1 s, the 20,000-vertex star about 140 million units and 11.7 s: 72 ns and 83 ns per unit, close enough):&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;// Scan work of filling (or clipping by) loops: for each screen row crossed by n edges,&lt;/span&gt;
&lt;span class="c1"&gt;// n·log₂n, plus one per edge. k is the view scale; ty and H place the rows on screen.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scanUnits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArrayLike&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&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;loops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;][],&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Int32Array&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// edges crossing each row, as a difference array&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;edges&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rowOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ty&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;k&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="nx"&gt;H&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;loops&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;v&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rowOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rowOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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;y0&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;edges&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="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&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;units&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;edges&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;r&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;r&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="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;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;units&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&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;units&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 real one skips the array when few rows are crossed, among other details.)&lt;/p&gt;

&lt;p&gt;Measured in Chromium across fill and clip, both fill rules, at 1× and 2×, one unit took 5–138 ns. Going by the slowest, a step is 200,000 units (about 30 ms), and any single fill over 2 million units (about 0.3 s) is drawn as its outline instead, with a note under the drawing: "Filled shapes drawn as their outline only, too heavy to fill: N". Among the 34 real drawings I have, none were outlined.&lt;/p&gt;

&lt;p&gt;Slicing by time still leaves the same hole if the size of a step is a count. The cap on a step should be an estimate of the operation's real work, calibrated by measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  A measuring trap: the canvas draws later
&lt;/h2&gt;

&lt;p&gt;At first, the time slicing measured the wrong thing:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&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="k"&gt;while &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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;drawNextChunk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// "drew for 20 ms"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Canvas calls are recorded when you make them and rasterised later, all at once. Stroking long lines 1,024 at a time, 429 chunks (440,000 lines) were "drawn" within 20 ms, and reading back a single pixel afterwards took 1.7 s. Recording raced ahead; the rasterising arrived at the end of the frame.&lt;/p&gt;

&lt;p&gt;So after each step, the renderer reads one pixel, which makes that step's drawing actually happen, and only then checks the clock:&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="k"&gt;while &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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;SLICE_MS&lt;/span&gt;&lt;span class="p"&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;             &lt;span class="c1"&gt;// draw one step&lt;/span&gt;
  &lt;span class="k"&gt;this&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;getImageData&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;1&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="c1"&gt;// make it happen now; time is measured up to here&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* finished */&lt;/span&gt; &lt;span class="k"&gt;return&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="nf"&gt;requestAnimationFrame&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Tests: run anything that might hang in its own process
&lt;/h2&gt;

&lt;p&gt;If the bounding breaks, a crafted sample hangs the test run itself. So each build runs in a process of its own, and the test checks things like the time (8 s), the peak memory (1 GB), the size handed to the page (200 MB) and that it stayed within budget. There are 44 such samples now.&lt;/p&gt;

&lt;p&gt;One more trap here: The child was started with &lt;code&gt;spawnSync('npx', ['tsx', ...], { timeout })&lt;/code&gt;, and a timeout killed only npx. The tsx and node under it lost their parent and kept burning CPU for hours. Now node is started directly (&lt;code&gt;process.execPath&lt;/code&gt; with &lt;code&gt;--import tsx&lt;/code&gt;), and a timeout is SIGKILL.&lt;/p&gt;

&lt;p&gt;The browser test sets a 20 ms &lt;code&gt;setInterval&lt;/code&gt; on the page and records the longest gap between ticks while opening, zooming and toggling a layer; over 600 ms fails. Measured again for this post, on the long lines, the heavy-print MTEXT, the 900 stars and 1.6 million distinct large circles: the longest stall was 116–214 ms at both 1× and 2×.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits that remain
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not freezing is not the same as being fast. In the 900-star drawing, all 900 are drawn as outlines, and it still takes 57 s to redraw after a zoom on a 2× screen (the page stays usable).&lt;/li&gt;
&lt;li&gt;A drawing stopped by the budget is drawn only in part (the page says so).&lt;/li&gt;
&lt;li&gt;The per-unit cost of scan work was calibrated in Chromium. I have not measured Firefox or Safari.&lt;/li&gt;
&lt;li&gt;I have tried it on only the 34 real drawings I have.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The viewer runs entirely in the browser: zoom, pan, toggle layers, save a PNG, and print fitted to one A4 or A3 sheet (or save it as a PDF from the print dialog). Files are never sent to a server.&lt;/p&gt;

&lt;p&gt;If you find a drawing that freezes it, or an ordinary drawing that gets the "Too many objects" or "outline only" note, I would like to hear about it. Every sample here was written by me, so real drawings surely have shapes I have not met yet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Article for readers (the viewer is embedded): &lt;a href="https://openedfile.com/blog/how-to-open-dxf-file/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dxf" rel="noopener noreferrer"&gt;How to open a .dxf file&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tool: &lt;a href="https://openedfile.com/tools/dxf/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dxf" rel="noopener noreferrer"&gt;DXF viewer&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was written with the help of AI (Claude). The code and details come from the actual implementation and were checked before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>performance</category>
      <category>canvas</category>
    </item>
    <item>
      <title>The redaction you can't see is still in the text layer</title>
      <dc:creator>Ren</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:57:35 +0000</pubDate>
      <link>https://dev.to/openedfile/the-redaction-you-cant-see-is-still-in-the-text-layer-3k5b</link>
      <guid>https://dev.to/openedfile/the-redaction-you-cant-see-is-still-in-the-text-layer-3k5b</guid>
      <description>&lt;p&gt;You have probably heard this story before: someone "redacts" a PDF by drawing black boxes over names, and the names are still there, searchable and copyable, because the box only covers the picture. The text layer underneath never changed.&lt;/p&gt;

&lt;p&gt;I found the same bug in my own code, in a document viewer I had written. This post is about what the hole looked like, and what it took to decide, character by character, whether text is actually visible on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: a DocuWorks viewer in the browser
&lt;/h2&gt;

&lt;p&gt;The viewer is for DocuWorks documents (&lt;code&gt;.xdw&lt;/code&gt; / &lt;code&gt;.xbd&lt;/code&gt;). DocuWorks is Fujifilm's document software, common in Japanese offices and local government; think of it as a PDF-like format you receive from a Japanese business partner or city office and cannot open without installing their viewer. My viewer runs entirely in the browser. The reader is &lt;a href="https://github.com/ezpzfile/ezpzfile-xdw" rel="noopener noreferrer"&gt;ezpzfile-xdw&lt;/a&gt; (MIT, written in Rust, not affiliated with Fujifilm) compiled to WebAssembly, and pages are drawn on a canvas. There are two save buttons: "Save as PDF" and "Save the text".&lt;/p&gt;

&lt;p&gt;While writing a help article, I read Fujifilm's support note &lt;a href="https://www.fujifilm.com/fb/ja/support/software/document-management/docuworks/info/dw10/105" rel="noopener noreferrer"&gt;DW10105&lt;/a&gt; (in Japanese). Its title translates to roughly "When a DocuWorks file is converted to PDF, information hidden with annotations can be seen." That made me wonder whether my viewer had the same problem. I built a test document with one line of body text covered by a sticky note and by a black rectangle, and tried it. It did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was happening
&lt;/h2&gt;

&lt;p&gt;Here is where the covered line ended up, by output:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Covered line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Screen (canvas)&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Save as PDF": the page image&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Save as PDF": the invisible text layer&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;present&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Save the text"&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;present&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The PDF was built as a page image (a 150 dpi JPEG) with an invisible text layer on top, for search and copy. The image uses the same drawing code as the screen, so the sticky note covers the line. But the text layer contained every piece of text on the page, covered or not. That is the classic black-box redaction failure, produced by my own export.&lt;/p&gt;

&lt;p&gt;The people who get hurt are recipients. Someone opens a document they were sent, saves it as a PDF or text, and forwards it. Whatever the sender meant to hide goes along with it, and nobody notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why: text extraction ignores what is drawn on top
&lt;/h2&gt;

&lt;p&gt;A DocuWorks page is a display list: text, shapes, images and annotations (sticky notes, rectangles), drawn in order. The engine's text extraction (&lt;code&gt;text()&lt;/code&gt;) and its PDF text layer (&lt;code&gt;toPdf&lt;/code&gt;) simply collect every text item in that list. Neither looks at what gets drawn over the text afterwards.&lt;/p&gt;

&lt;p&gt;According to DW10105, PDFs made by DocuWorks' own PDF exporter have a related problem: the original text, shapes and images and the annotations pasted on top are separate drawing elements. A slow viewer can show what is under a note for a few seconds, and PDF editing software can expose the covered text. The mechanism differs from mine (theirs keeps the annotations as separate, movable objects; mine put all text into the text layer), but Fujifilm's remedy is the same one I would reach for: &lt;strong&gt;produce an image-only PDF&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I could have dropped the text layer entirely. But being able to search a PDF is useful, and most documents have nothing hidden. So I decided to detect whether there is text the screen does not show, and ask before saving only when there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: look for what covers each character
&lt;/h2&gt;

&lt;p&gt;The first version was straightforward. For each character, check whether a fill, a thick stroke or an opaque image drawn &lt;em&gt;after&lt;/em&gt; it covers it.&lt;/p&gt;

&lt;p&gt;Each character is sampled at 3×3 points: 20%, 50% and 80% across and down its box. A character counts as hidden when its centre and at least 7 of the 9 points are unseen, so a stamp over one corner does not count.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fy&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;F&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fx&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;F&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="nf"&gt;unseen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fy&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="o"&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;fx&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fy&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;centre&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;centre&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;HIDDEN_POINTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// HIDDEN_POINTS = 7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The character boxes are placed exactly the way the screen renderer (&lt;code&gt;paint()&lt;/code&gt;) places them, because "hidden" should mean hidden on the screen.&lt;/p&gt;

&lt;p&gt;Sticky notes, black rectangles, white rectangles, a black highlighter, a thick pen line, an opaque image: every sample I had made was caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: text the same colour as what is under it
&lt;/h2&gt;

&lt;p&gt;Code review found shapes this missed. Draw a black rectangle first, then black text on top of it. Black text on a black background. White text on white paper. None of these can be read on screen, yet nothing is drawn &lt;em&gt;over&lt;/em&gt; the text, so the check counted zero.&lt;/p&gt;

&lt;p&gt;The first one is not contrived. Shade some words black in a word processor to "blank them out", print to DocuWorks, and you can end up with exactly that: black text on a black rectangle.&lt;/p&gt;

&lt;p&gt;So I changed the question from "is anything on top of it?" to "can the text colour be told apart from the colour under it at this point?" At each sample point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Composite everything drawn &lt;strong&gt;before&lt;/strong&gt; the text (white paper, fills, strokes, image pixels) to get the background colour&lt;/li&gt;
&lt;li&gt;Lay everything drawn &lt;strong&gt;after&lt;/strong&gt; the text over both the text colour and the background&lt;/li&gt;
&lt;li&gt;If the two colours differ by 60 or less (the sum of the absolute R, G and B differences, 0 to 765), the point is unseen
&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;under&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// white paper&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                     &lt;span class="c1"&gt;// the text colour&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;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;paintsNearThisPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;// in drawing order&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;under&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;blend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;under&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;blend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// paint after the text lands on the text too&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;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;under&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;CONTRAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// CONTRAST = 60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attempt 1 turns out to be a special case of this: when an opaque fill lands on top, &lt;code&gt;top&lt;/code&gt; and &lt;code&gt;under&lt;/code&gt; become the same colour and the difference is 0.&lt;/p&gt;

&lt;p&gt;Blending follows canvas. A highlighter is drawn with multiply, so black text under a yellow highlighter stays black and is not counted. Under a black highlighter it is black on black and is hidden. Translucent images are mixed by their opacity.&lt;/p&gt;

&lt;p&gt;Some covers are made of glyphs: a run of █ typed over the text. Unicode's Block Elements (U+2580 to U+259F) define which part of the character cell they fill, so I treat each one as a filled rectangle. ■ (U+25A0) is a centred square, 0.1 to 0.9 of the box both ways. In both cases I measured the positions in Chromium with the viewer's fonts. A line covered with ▀ (upper half only) or ▓ (dark shade) is still readable on screen, and the check agrees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threshold of 60 is checked against pixels
&lt;/h2&gt;

&lt;p&gt;I did not pick 60 by eye. Separately from the check, I wrote a script that compares pixels. It renders the page twice with the same &lt;code&gt;paint()&lt;/code&gt;, once with a given text item and once without it, and if no pixel inside the character's box differs by more than 60 (the same three-channel sum), that character is not visible on screen. It redraws the page once per text item, so it is far too slow for the viewer; it is the ruler I measure the check against.&lt;/p&gt;

&lt;p&gt;To pin down the boundary, I made samples whose colour difference is exactly 60 and 63, in three arrangements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Pixel comparison&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#ebebeb text on white paper&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#eaeaea text on white paper&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#141414 text on a black rectangle&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#151515 text on a black rectangle&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#141414 highlighter over black text&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;td&gt;hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#151515 highlighter over black text&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A #202020 highlighter (a difference of 96 from black text) still lets you read the text in a photo of the screen, and the check says "visible".&lt;/p&gt;

&lt;h2&gt;
  
  
  "Unknown" is not "none"
&lt;/h2&gt;

&lt;p&gt;The check runs over every page in a Web Worker right after the document opens. Each page answers with either "N characters, K hidden" or "could not be read", and a pure function sums up the document:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;summarizeChecks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PageCheck&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;HiddenSummary&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;perPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;checks&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unreadable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;perPage&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="p"&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;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;perPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;perPage&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;k&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;k&lt;/span&gt; &lt;span class="o"&gt;&amp;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;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;perPage&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="na"&gt;kind&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It did not start out like this. A page where the engine stopped (a WebAssembly trap) used to be added as "0 hidden". Code review caught that too. Adding it as 0 makes the whole document "none", and the viewer would save with text without asking. A page that could not be read is precisely the page whose contents you do not know, so one unreadable page makes the whole document "unknown".&lt;/p&gt;

&lt;p&gt;The same rule applies elsewhere. If the caller forgets to pass the pixels of an image the check needs, it throws, which ends in "unknown". An image that is itself broken and cannot be decoded, on the other hand, covers nothing: it is not drawn on screen or in the PDF image either, so it really does not hide anything.&lt;/p&gt;

&lt;p&gt;The UI keeps four states: checking, unknown, none, found. "Unknown" never looks the same as "none".&lt;/p&gt;

&lt;h2&gt;
  
  
  A notice on open, a dialog before saving
&lt;/h2&gt;

&lt;p&gt;When the result is "found", a yellow notice appears right after the document opens:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Some text can't be seen on screen, for example because it is under sticky notes or filled shapes (22 characters on 1 page). When you save a PDF or the text, you can choose whether to include it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Pressing a save button always uses the check's result. If the background check ended in "unknown", it runs once more. If there is hidden text, a dialog asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF: &lt;strong&gt;Save image-only PDF&lt;/strong&gt; (primary) / Save with text / Cancel&lt;/li&gt;
&lt;li&gt;Text: &lt;strong&gt;Save hidden text as ■ (recommended)&lt;/strong&gt; / Save everything / Cancel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool cannot know whether the hiding was intentional. Some people just stick a note over their own scribbles. So I kept every choice, made the option that keeps hidden text out the primary button, and put the initial focus on it.&lt;/p&gt;

&lt;p&gt;When the check could not be completed, the dialog says "Couldn't check for hidden text". For text, there is then no way to know which characters to replace with ■, so the only choices are "Save everything" and "Cancel". In that case the initial focus goes to Cancel, so pressing Enter does not save everything.&lt;/p&gt;

&lt;p&gt;The image-only PDF is just one 150 dpi JPEG per page, drawn by the same &lt;code&gt;paint()&lt;/code&gt; as the screen, with no text layer. Writing the PDF by hand took about 70 lines.&lt;/p&gt;

&lt;p&gt;The ■ text cannot be made by rewriting the engine's &lt;code&gt;text()&lt;/code&gt; output afterwards, because a plain string does not tell you which character came from which display item. I reimplemented the engine's text assembly in JavaScript (a new line when the baseline moves or the text is rotated, a space where items on a line are apart), and a test checks that without masking it matches the engine's &lt;code&gt;text()&lt;/code&gt; character for character on every page of every sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known limits
&lt;/h2&gt;

&lt;p&gt;The check does not agree with the pixel comparison perfectly. Running about 60 samples through the same script, most disagreements were "the check says hidden, the pixels show a few visible pixels":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;characters at the edge of a cover that only covers the left part of a line&lt;/li&gt;
&lt;li&gt;covers made of a run of █ (thin gaps remain between glyphs)&lt;/li&gt;
&lt;li&gt;a black bar covering 70 to 80% of the line height (the top edge of the characters peeks out)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those over-count, so the cost is an extra notice and dialog. A few disagreements go the other way: the pixels say hidden, the check says visible. A hyphen in the shadow of a bar covering about half the line, and one character under a cover of bold ■. In each case, the other characters on the same line are readable on screen.&lt;/p&gt;

&lt;p&gt;Things it does not look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;text cut away by its own clip, and text too small to be drawn&lt;/li&gt;
&lt;li&gt;dashed lines (they have gaps, so they do not count as covers)&lt;/li&gt;
&lt;li&gt;"text" inside scanned images (it was never in the text layer, so it is out of scope)&lt;/li&gt;
&lt;li&gt;fine detail in images (JPEG images are scaled down to 1,024 pixels on the long side before their pixels are read)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the notice says what it found. It never says "no hidden text".&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are the one sending the document
&lt;/h2&gt;

&lt;p&gt;This check is a safety net for recipients, so they do not leak something by accident. It does not make the sender's redaction reliable. If you need to hide part of a document before sharing it, do not stop at drawing notes or boxes over it. Convert it to an image-only PDF first, as DW10105 recommends; in DocuWorks that is the image PDF output (画像PDF出力) of DocuWorks PDF, or the convert-to-image plug-in (画像に変換) in DocuWorks Desk. The same applies to any format: flatten to pixels, or use a real redaction tool that removes the text. Before you send it, search the finished PDF for the words you hid and make sure nothing comes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The in-browser DocuWorks viewer now warns about hidden text and can save an image-only PDF or text with ■ in place of hidden characters. Files are never sent to a server.&lt;/p&gt;

&lt;p&gt;If you find a way of covering text that the check misses, or a normal document where the notice shows up for no reason, I would like to hear about it. Every sample I tested with was built from scratch, so there are surely real-world covering methods I have not met yet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Article for readers (the viewer is embedded): &lt;a href="https://openedfile.com/blog/how-to-open-xdw-file/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=xdw-hidden" rel="noopener noreferrer"&gt;How to open an .xdw file&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tool: &lt;a href="https://openedfile.com/tools/xdw/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=xdw-hidden" rel="noopener noreferrer"&gt;DocuWorks viewer&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was written with the help of AI (Claude). The code and details come from the actual implementation and were checked before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>privacy</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Writing a TNEF parser so winmail.dat never leaves the browser</title>
      <dc:creator>Ren</dc:creator>
      <pubDate>Tue, 20 Oct 2026 01:00:20 +0000</pubDate>
      <link>https://dev.to/openedfile/writing-a-tnef-parser-so-winmaildat-never-leaves-the-browser-3lmi</link>
      <guid>https://dev.to/openedfile/writing-a-tnef-parser-so-winmaildat-never-leaves-the-browser-3lmi</guid>
      <description>&lt;p&gt;If you have ever received an email from Outlook with an attachment called &lt;code&gt;winmail.dat&lt;/code&gt;, you know the feeling: no usable extension, nothing opens it, no hint of what is inside. When I went looking for a converter, every one I found said the same thing first: "upload your file to our server."&lt;/p&gt;

&lt;p&gt;A winmail.dat is, by definition, an email somebody sent you. It might be a contract or an invoice. Handing it to a site you know nothing about just to find out what is in it gets the order backwards. So I implemented TNEF in TypeScript and made the whole thing run in the browser.&lt;/p&gt;

&lt;p&gt;Some background, if you have been lucky enough never to meet it: when Outlook sends a message in Rich Text Format, it does not send normal MIME with normal attachments. It packs the subject, the formatted body, every attachment and a pile of Outlook metadata into one binary blob, encoded as TNEF (Transport Neutral Encapsulation Format), and attaches that as &lt;code&gt;winmail.dat&lt;/code&gt;. Outlook on the other end unpacks it silently, so the sender never sees a problem. Everyone else gets the blob.&lt;/p&gt;

&lt;p&gt;This post covers the three places where a straightforward implementation breaks, and two problems I only found after shipping: bodies that looked empty, and sender addresses stored in a form you cannot send mail to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The container itself is simple
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signature   uint32   0x223e9f78
key         uint16

repeat until EOF:
  level     uint8    1 = message, 2 = attachment
  attribute uint32   high 16 bits = data type, low 16 bits = attribute id
  length    uint32
  payload   byte[length]
  checksum  uint16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No tree, no index, no offsets to chase. It is a flat run of attribute records, so reading it is a loop. That part worked in an afternoon. The trouble was inside the MAPI property blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Everything is 4-byte aligned
&lt;/h2&gt;

&lt;p&gt;String and binary values are length-prefixed, but the length is the number of &lt;strong&gt;meaningful&lt;/strong&gt; bytes, not the number of bytes written. Each value is padded to the next 4-byte boundary.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUint32LE&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;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pad4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// forget this line and everything after it is garbage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nasty part is how it fails. You do not get a slightly odd filename. The stream slips by one to three bytes, and every property type you read after that is nonsense. It fails silently and completely, which makes it very easy to conclude "this file is corrupt" rather than "my reader is off by two."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Named properties have a variable-length header
&lt;/h2&gt;

&lt;p&gt;This one cost me the most time. A property id of &lt;code&gt;0x8000&lt;/code&gt; or above is a &lt;strong&gt;named&lt;/strong&gt; property, and it is preceded by an extra header that is not mentioned anywhere near the description of the record layout.&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;propId&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skip&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="c1"&gt;// property set GUID&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUint32LE&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;kind&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                      &lt;span class="c1"&gt;// named by numeric id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;nameLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUint32LE&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pad4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nameLen&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;          &lt;span class="c1"&gt;// named by string; length depends on the name&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;Skip a fixed number of bytes here and it works on your test files, then breaks on somebody's real mail. Same failure as the alignment bug: total desync, no error.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Filenames live in four places, in a priority order
&lt;/h2&gt;

&lt;p&gt;This is the one that matters in practice. The same attachment can carry its name in up to four fields, and they do not agree.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mapiLongFilename&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;   &lt;span class="c1"&gt;// 0x3707, UTF-16LE, full name&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mapiFilename&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;       &lt;span class="c1"&gt;// 0x3704&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mapiDisplayName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;    &lt;span class="c1"&gt;// 0x3001&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legacyName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;         &lt;span class="c1"&gt;// attAttachTitle, ANSI, often 8.3&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attachment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;attAttachTitle&lt;/code&gt; sits right next to the attachment bytes, so it is the easiest one to find. But it is an "ANSI" string in whatever legacy code page the sender's Windows used, and it is often truncated to 8.3. On a Japanese system that code page is Shift_JIS (code page 932), the two-byte encoding Windows still uses for non-Unicode text in Japanese locales. A parser that reads only this field produces filenames like &lt;code&gt;8E9095948E.PDF&lt;/code&gt; instead of what the sender typed, which is exactly the mojibake people complain about in winmail.dat tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The encoding is declared, not guessed
&lt;/h2&gt;

&lt;p&gt;The encoding of ANSI strings is declared by attribute &lt;code&gt;0x9007&lt;/code&gt; (the OEM code page): 932 is Shift_JIS, 936 is GBK, 949 is EUC-KR, 950 is Big5, the 125x range is the Windows Latin family, 65001 is UTF-8.&lt;/p&gt;

&lt;p&gt;It governs every ANSI string that comes &lt;strong&gt;after&lt;/strong&gt; it, so the parse is order-dependent. You have to read and apply the code page before decoding the subject. Process attributes in whatever order is convenient and you will quietly decode a Japanese subject line as Windows-1252.&lt;/p&gt;

&lt;h2&gt;
  
  
  Found later: the body often exists only as compressed RTF
&lt;/h2&gt;

&lt;p&gt;For a while after launch, many winmail.dat files showed up as "no body". The body was there. It was stored as Outlook's compressed RTF (&lt;code&gt;PR_RTF_COMPRESSED&lt;/code&gt;, &lt;code&gt;0x1009&lt;/code&gt;), and in a winmail.dat that is often the only copy of the body.&lt;/p&gt;

&lt;p&gt;The compression (&lt;a href="https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxrtfcp/" rel="noopener noreferrer"&gt;MS-OXRTFCP&lt;/a&gt;) is a small LZ77 variant. Its 4,096-byte circular dictionary starts out holding 207 bytes of common RTF:&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;// MS-OXRTFCP 2.1.2.1&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RTF_DICTIONARY_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;rtf1&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;ansi&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;mac&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;deff0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;deftab720{&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;fonttbl;}{&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;f0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;fnil &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;froman &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;fswiss &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;fmodern &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;fscript &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;fdecor MS Sans SerifSymbolArialTimes New RomanCourier{&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;colortbl&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;red0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;green0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;blue0&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;par &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;pard&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;plain&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;f0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;fs20&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;b&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;i&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;tab&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;tx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each bit of a control byte says whether the next token is a literal byte or a reference into the dictionary, eight tokens per control byte. A reference can read into the very bytes it is writing, so the copy has to go one byte at a time. The compressed data carries a CRC, so you know it is damaged before you decompress it.&lt;/p&gt;

&lt;p&gt;The RTF that comes out is usually not RTF anyone wrote. When Outlook stores an HTML message this way, it wraps the HTML in RTF (&lt;a href="https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxrtfex/" rel="noopener noreferrer"&gt;MS-OXRTFEX&lt;/a&gt;). The tags go into &lt;code&gt;{\*\htmltag …}&lt;/code&gt; groups, and whatever Outlook added for RTF readers sits between &lt;code&gt;\htmlrtf&lt;/code&gt; and &lt;code&gt;\htmlrtf0&lt;/code&gt;. Drop that part, join the rest, and the original HTML comes back. It then goes through the same treatment as any other HTML body from a stranger: sanitized with DOMPurify against an allowlist, and rendered in a sandboxed iframe whose content security policy blocks network loads, so remote tracking images never fire.&lt;/p&gt;

&lt;p&gt;The encoding lesson applies here too. The RTF declares its own code page (&lt;code&gt;\ansicpg932&lt;/code&gt;), and a double-byte character arrives as two separate escapes, &lt;code&gt;\'82\'a0&lt;/code&gt;. If you decode each escape on its own, you get garbage; collect the bytes first and decode them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also found later: the sender's address is stored in a form you cannot send to
&lt;/h2&gt;

&lt;p&gt;Since winmail.dat is created by the sender's Outlook settings, the recipient cannot stop it from happening. So under the result I show a short "here is what to ask the sender" message and a &lt;code&gt;mailto:&lt;/code&gt; link that opens it in the user's mail app. I wanted the sender's address in the To field. That turned out to be harder than it sounds.&lt;/p&gt;

&lt;p&gt;The sender is recorded in two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MAPI sender properties (&lt;code&gt;PidTagSenderName&lt;/code&gt; / &lt;code&gt;PidTagSenderEmailAddress&lt;/code&gt;, plus the &lt;code&gt;PidTagSentRepresenting…&lt;/code&gt; pair for sending on behalf of someone)&lt;/li&gt;
&lt;li&gt;The TNEF attribute &lt;code&gt;attFrom&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxtnef/" rel="noopener noreferrer"&gt;MS-OXTNEF&lt;/a&gt; 2.1.3.3.3): an 8-byte header (0x0004, the structure's length, the name's length, the address's length), then the name, then the address as &lt;code&gt;type:address&lt;/code&gt;, then 8 zero bytes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For mail sent from inside an Exchange organization, both addresses are often X.500 paths like &lt;code&gt;EX:/O=EXCHANGELABS/OU=…/CN=RECIPIENTS/CN=…&lt;/code&gt;. No mail app can send to that. The internet address lives in yet another pair of properties: &lt;code&gt;PidTagSenderSmtpAddress&lt;/code&gt; (0x5D01) and &lt;code&gt;PidTagSentRepresentingSmtpAddress&lt;/code&gt; (0x5D02).&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;// Use the first candidate that is an internet address.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isInternetAddress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;@&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;smtp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mapiEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attFromEmail&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isInternetAddress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If only the X.500 path exists, the From line still shows it (that is what the file holds), but the &lt;code&gt;mailto:&lt;/code&gt; link leaves the To field empty for the user to fill in.&lt;/p&gt;

&lt;p&gt;Delegated sending adds one more trap. When an assistant sends on behalf of their manager, the "sender" and "sent representing" names differ. Name and address have to come from the same person, or you build a recipient with the manager's name and the assistant's address. The name in &lt;code&gt;attFrom&lt;/code&gt; is ANSI, so characters can arrive as &lt;code&gt;?&lt;/code&gt;; I only compare names when both are readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The result is a winmail.dat viewer that runs entirely in the browser. You can check that nothing is uploaded: open the Network tab in your browser's developer tools and convert a file.&lt;/p&gt;

&lt;p&gt;It does not yet extract emails attached inside the winmail.dat, or OLE objects.&lt;/p&gt;

&lt;p&gt;If you have a winmail.dat it opens wrong, I would like to hear about it. This format has more corners than one person's inbox can cover.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool: &lt;a href="https://openedfile.com/tools/winmail/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tnef" rel="noopener noreferrer"&gt;winmail.dat viewer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;For non-developers (the viewer is embedded in the article): &lt;a href="https://openedfile.com/blog/how-to-open-winmail-dat/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tnef" rel="noopener noreferrer"&gt;How to open winmail.dat&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The longer write-up (HTML sanitizing, what is not supported): &lt;a href="https://openedfile.com/blog/writing-a-tnef-parser/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tnef" rel="noopener noreferrer"&gt;Writing a TNEF parser so winmail.dat never leaves the browser&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was written with the help of AI (Claude). The code and details come from the actual implementation and were checked before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>email</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
