---
title: "RTF bodies: compressed RTF and de-encapsulation"
url: https://recursiveai.org/onemail/docs/rtf/
description: "RecursiveAI.OneMail.Rtf reads and writes what Outlook stores in PidTagRtfCompressed and recovers the HTML or plain text that Outlook wraps inside an RTF…"
version: "0.3.0-preview.2"
source: https://github.com/recursive-ai-oss/onemail-csharp/blob/bb4c9543917ab4ad466b5200d132e1899cb3ca14/docs/rtf.md
---

# RTF bodies: compressed RTF and de-encapsulation

`RecursiveAI.OneMail.Rtf` reads and writes what Outlook stores in `PidTagRtfCompressed` and recovers the HTML or plain text
that Outlook wraps inside an RTF body. The namespace uses only the .NET base class library: it does not reference any other
part of OneMail, so it can be lifted out as the seed of a general RTF reader. Nothing in it throws on malformed input: damage
is a typed `RtfDefect` with a byte offset, and the ambiguity entries (RT1–RT33) are in [ambiguities.md](https://recursiveai.org/onemail/docs/ambiguities/).

| Piece | Type | Spec |
|---|---|---|
| Tokenizer | `RtfReader` (`ref struct`) | RTF 1.9.1, lexical rules |
| Decompress | `Compression.RtfDecompressor` | MS-OXRTFCP 2.2 |
| Compress | `Compression.RtfCompressor` | MS-OXRTFCP 2.3 |
| HTML/text out of RTF | `RtfDeencapsulator` | MS-OXRTFEX 2.2 |
| HTML/text into RTF | `RtfEncapsulator` | MS-OXRTFEX 2.1 |

## Compressed RTF (MS-OXRTFCP)

The stream is a 16-byte header (COMPSIZE, RAWSIZE, COMPTYPE, CRC, little-endian) and contents. `LZFu` contents are
LZ77 over a 4096-byte circular dictionary that starts with 207 bytes of common RTF text; a control byte tells which of the next
eight tokens are literals (1 byte) or references (2 bytes, big-endian: 12-bit dictionary offset, 4-bit length minus two). A
reference to the dictionary's write position ends the data. `MELA` contents are the RTF itself. The CRC is the reflected
CRC-32 polynomial (0xEDB88320) with a zero start value and no final inversion, over the contents (padding included).

**Decompression** (`RtfDecompressor.Decompress`) checks, in this order, and reports each failure without stopping unless it
cannot continue:

| Check | Defect | Output |
|---|---|---|
| fewer than 16 bytes | `HeaderTruncated` | empty |
| type neither `LZFu` nor `MELA` | `UnknownCompressionType` | empty |
| COMPSIZE + 4 differs from the bytes present | `CompressedSizeMismatch` | the declared range, or what is there |
| RAWSIZE above `MaxOutputBytes` (256 MiB) | `OutputLimitExceeded` | empty |
| RAWSIZE above 20 × the input length | `ExpansionRatioExceeded` | empty |
| data ends before the end marker | `MissingEndMarker` | everything decoded |
| reference to a dictionary position nothing was written to | `ReferenceBeyondDictionary` | zero bytes used |
| CRC differs | `CrcMismatch` | everything decoded |
| bytes produced differ from RAWSIZE | `RawSizeMismatch` | everything decoded (the buffer grows up to the limit) |

The size and ratio checks run before the output buffer is allocated. Honest data cannot expand more than 17 bytes per 2-byte
reference (8.5 : 1), so the ratio limit of 20 never refuses real data. `MELA` copies the contents and does not validate the CRC
(MS-OXRTFCP 2.2.3.1). `RtfDecompressionResult.CrcVerified` is true only for `LZFu` data whose CRC matched.

**Compression** (`RtfCompressor.Compress`) searches the 4095 bytes before the cursor through a hash chain of two-byte pairs
(at most 128 candidates per position), takes the longest match of 2 to 17 bytes (the older one on a tie, as the specification's
oldest-first scan does), and writes the end marker. Matches may overlap the bytes being written, which is what the
decoder's byte-at-a-time copy expects. Output decompresses back to the input exactly with `RtfDecompressor` and with the
oracle; it reproduces both worked examples of the specification byte for byte. It is not required to equal Outlook's own bytes
(see Measurements). An empty input becomes `01 0C F0` (an end marker only); Outlook's writer adds a NUL byte there, which
the reader treats as one byte of output (RT18).

## The tokenizer (`RtfReader`)

A forward-only reader over a `ReadOnlySpan<byte>`: `GroupStart`, `GroupEnd`, `ControlWord` (name, optional signed number,
one optional delimiting space that belongs to the word), `ControlSymbol` (`\*`, `\~`, `\{` …), `HexEscape` (`\'hh`), `Text` (a
run of bytes), and `Binary` (the `N` bytes after `\binN`, not parsed). CR and LF between tokens are skipped, as RTF requires.
A backslash before a line break is a control symbol that readers treat as `\par`. It never interprets code pages,
destinations or Unicode: those are the caller's.

Defects: `UnbalancedGroupEnd`, `UnclosedGroups`, `ParameterOverflow` (clamped to `int.MaxValue`), `InvalidHexEscape`,
`BinaryDataTruncated`, `TruncatedToken`, `NestingLimitExceeded` (default depth 1000, then reading stops).

## De-encapsulation (MS-OXRTFEX 2.2)

`Detect` follows 2.2.3.1: the document starts `{\rtf`, and among its first ten tokens (all group starts or control words)
`\fromhtml1` or `\fromtext` appears. `\fromhtml`, `\fromhtml0` and any other form do not count (RT12).
`Deencapsulate` returns the text as a .NET string, with the document's default code page. All bytes are decoded, so there is
no byte-oriented result to mis-label.

HTML (`\fromhtml1`):

- `{\*\htmltag…}` group content is copied, regardless of `\htmlrtf` (RT6); the numeric parameter is ignored. `\*\mhtmltag` and every
  other ignorable destination are skipped.
- Outside `\htmltag`, text and control words under `\htmlrtf` (or `\htmlrtf1`) are dropped until `\htmlrtf0`. The state is
  saved on `{` and restored on `}`. The current font is tracked even in suppressed text.
- `\par` and `\line` become CRLF, `\tab` a tab. Other control words are ignored.
- Destinations that hold no body text are skipped (`\colortbl`, `\stylesheet`, `\info`, `\pict`, `\object`, headers and footers,
  `\fldinst`, list and paragraph-number groups, bookmarks…); `\fonttbl` is read for code pages only (RT13).

Plain text (`\fromtext`): the same walk with no `\htmltag` groups; `\htmlrtf` is ignored (RT10).

**Characters.** `\uN` is a signed 16-bit UTF-16 unit; the next `\ucN` characters (default 1) are its fallback and are skipped.
A skipped character is a text byte or a `\'hh` escape; control words do not count, and `{` or `}` ends the skipping (RT8). `\uc`
is scoped to its group. An unpaired surrogate becomes U+FFFD (`LoneSurrogate`). `\'hh` and text bytes are decoded as runs in the code page of the
current font: `\cpgN` of the font, else the charset of `\fcharsetN` (0 is windows-1252 whatever `\ansicpg` says; 1 means the
document's code page), else `\ansicpgN` (or what `\ansi`, `\mac`, `\pc`, `\pca` imply, else 1252) (RT9). `\htmltag` content always
uses the document's default code page. Runs are decoded together so that two-byte characters split across `\'hh\'hh` come out
whole. Code pages come from `RtfDeencapsulationOptions.CodePageDecoder`, or by default from the base library's encodings and
`CodePagesEncodingProvider` (no global registration); an unknown code page uses the default one and reports
`AmbiguousUnknownCodePage` (RT11).

Control words and symbols with a character meaning: `\lquote \rquote \ldblquote \rdblquote \bullet \endash \emdash \enspace
\emspace \qmspace \zwj \zwnj \ltrmark \rtlmark`, `\~` (U+00A0), and `\_` (U+00AD inside `\htmltag`, U+2011 elsewhere) (RT5, RT7).
`\{ \} \\` are the literal characters.

## Encapsulation (MS-OXRTFEX 2.1)

`EncapsulateHtml` writes `{\rtf1\ansi\ansicpg1252\fromhtml1 \deff0{\fonttbl…}`, the empty `{\*\htmltag64}`, then walks the
HTML: markup (tags, comments, doctype, and `<script>`/`<style>` with their content) goes into `{\*\htmltagN …}` with the
TagType flag as parameter (plus 8 for closing tags); entities go into `\htmltag0` with their rendering after `\htmlrtf`; line
breaks in text become `\htmltag0 \par` plus a suppressed space; everything else is visible RTF text. The output is 7-bit ASCII
(`\uN?` for non-ASCII, `\'hh` for control characters). `EncapsulateText` writes `\fromtext` and the text with `\par` for every
line break. This is not an HTML-to-RTF converter: no formatting is produced.

Line breaks (CR, LF or CRLF) come back as CRLF, which MS-OXRTFEX 1.3.1 allows; everything else round-trips exactly.

## Measurements and method

`tools/rtf` extracts the RTF, HTML and text body streams from the public Tika and POI `.msg` files (OneArchive's compound-file
reader; `tools/corpus/fetch-msg.sh` fetches the files), runs OneMail over them, and compares with released packages run as
black boxes. See [tools/rtf/README.md](https://github.com/recursive-ai-oss/onemail-csharp/blob/bb4c9543917ab4ad466b5200d132e1899cb3ca14/tools/rtf/README.md) for the commands and the oracle versions.

On 55 `.msg` files (53 read; two fuzzer-minimised files have a damaged compound file) there are 45 compressed RTF bodies, 27
distinct streams:

| Check | Result |
|---|---|
| decompression vs compressed-rtf 1.0.7 | 45 / 45 byte-identical; header CRC verified on 45 / 45; no defects |
| our compression read back by OneMail and by compressed-rtf | 45 / 45 identical to the input |
| compressed-rtf's compression read back by OneMail | 45 / 45 identical, CRC verified |
| our compressed size vs Outlook's own stream | 97,438 vs 97,422 bytes over the 27 distinct streams (1.0002 ×); 3 of 45 streams byte-identical, the rest differ only in which of several equal-length matches is used |
| de-encapsulation vs RTFDE 0.1.2.2 | 31 HTML and 4 text results: identical apart from RT1 (all) and RT2 (4 files) |
| de-encapsulation vs the message's own `PidTagHtml` / `PidTagBody` | 4 text bodies identical; 6 HTML bodies: 5 differ only by an injected content-type `<meta>` (RT3), 1 is the same text with re-serialised markup (RT4) |
| encapsulate, then de-encapsulate, every recovered and own HTML and text body | 98 bodies: 97 exact, 1 equal once its LF line endings became CRLF, 0 different |
| 10 RTF bodies without `\fromhtml1`/`\fromtext` | `NotEncapsulated`; RTFDE refuses them too |
| 3,000 damaged streams (truncations, bit flips, header edits, insertions) | OneMail reports a defect on 2,998; the 2 without are valid; oracle refuses 2,616 of them; bytes equal wherever both decode, except 123 where OneMail's size or ratio limit refused what the oracle decoded (RT14) |

Corpus bodies are not committed; the specification's example blocks are in `testdata/rtf` and the tests.

## Native RTF to HTML (`RtfHtmlConverter`)

Outlook items whose RTF was authored as RTF (no `\fromhtml`/`\fromtext`) have no other body. `RtfHtmlConverter.Convert(bytes, options)`
turns them into HTML and plain text; the MAPI view uses it when an item has no HTML or text body (`MailBody.Origin` is
`RtfConverted`). The same independence rule holds: the namespace uses only the BCL (a test scans the sources).

- **Model.** A parse pass builds a small document model (paragraphs, runs, lists, tables, pictures, links) from fonts, colours,
  the stylesheet (names only, for headings), list tables and `\pn` lists, fields (HYPERLINK), shapes, `\object` results,
  `\objattph` positions, `\u`/`\uc` and code pages (RT19). Both writers read that model, so the text always matches the HTML.
- **HTML shape.** One `<div class="rtf-body">` fragment with the dominant font and size on the div; `p`, `h1`–`h6`, `ul`/`ol`/`li`,
  `table`, `a`, `img` and a few inline spans. Images are `data:` URIs by default, `cid:` with `RtfImageMode.ContentId`. EMF/WMF become
  an attachment-style placeholder. The output is well-formed and identical for identical input.
- **Text.** CRLF line breaks; list markers and tab-separated cells.
- **Limits** (`RtfHtmlOptions`): group depth 1000, 4 M nodes, 64 Mi output characters, 16 MiB per picture, 64 MiB of pictures; a
  hit is a typed defect and the output so far is kept. No input throws.
- **Defects added:** `InvalidPictureData`, `PictureLimitExceeded`, `UndefinedReference`, `NestedTableFlattened`,
  `AmbiguousPictureFormat`, `AmbiguousListLevel`, `AmbiguousSymbolFont`, `ContentLimitExceeded`, `AmbiguousBareCell`.
- **Resolutions:** RT19–RT33 in [ambiguities.md](https://recursiveai.org/onemail/docs/ambiguities/). RT19 differs from RT9 on purpose: the de-encapsulator reads RTF that
  Outlook wrote around HTML (the specification's own example has `\fcharset0` text under `\ansicpg1251`, decoded as 1252), while the
  converter reads RTF authored by Word and others, where `\fcharset0` means "ANSI" and the document's `\ansicpg` is the code page
  (LibreOffice 26.2.6 agrees; 24.2.7, pandoc and unrtf decode as 1252). No client was observed on either; both are provisional.

### Method and results

`tools/rtf/run-html-oracles.sh` converts every document with pandoc 3.13, unrtf 0.21.10 and LibreOffice 24.2.7 (`--convert-to
html`), all run as released programs, and `htmlcompare.py` reduces every output to a normalised model: words, which words are
bold/italic/underlined/struck/super-/subscript, list items, table cells, link targets, heading levels, image count. HTML bytes are
never compared. A metric an oracle never produces in any document (unrtf: lists, headings) is n/a for it. Every remaining
difference needs a rule in `tools/rtf/explained-html.tsv`; LibreOffice rows are itemised per document, pandoc and unrtf rows are
**class-level** (they decode every `\'hh` as 1252, drop heading styles and lists, and so on), restricted to the generated, Tika and
POI documents, so a difference on any other document would show up as unexplained.

123 documents: 17 native RTF bodies from the MSG/OST/PST/TNEF corpora (of 52 RTF bodies in 85 items; 35 are encapsulated), 43 Tika/POI
`.rtf` test documents, and 63 generated (23 feature documents, 40 seeded mixes).

| Oracle | Metrics agreeing | Explained | Unexplained | n/a |
|---|---|---|---|---|
| LibreOffice 24.2.7 | 1,446 | 18 | 0 | 0 |
| pandoc 3.13 | 1,327 | 113 | 0 | 0 |
| unrtf 0.21.10 | 1,105 | 125 | 0 | 246 |

On the 17 native bodies LibreOffice agrees on every metric; pandoc and unrtf differ only on two Chinese bodies (lists, tables, code
page). Text content of every native body agrees with LibreOffice.

Allocations (Release): over 83 documents (2,282 KB of RTF) 1,559 B per KB of RTF for HTML and 1,891 B/KB for HTML plus text; documents
without pictures (662 KB): 2,990 and 3,978 B/KB. Tests include truncation at every length and 3,000 seeded mutants; the
`rtf-html` fuzz target checks that the HTML is well-formed and deterministic.
