<?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>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>
