Webfold
Menu

Format · RTF

RTF in and out: a structured reader, Markdown and plain text, and an ASCII writer.

Webfold parses RTF into paragraphs, list items, nested tables, hyperlinks, footnotes and metadata, renders Markdown or plain text from that model, and writes ASCII-only RTF back: on 6 RTF fixtures its hot median is 5.2 µs against 94.6 µs for anydoc, another document converter, measured on 24 September before the rewrite, and the structured reader is in the main branch, not in the 0.1.0 release.

Parses RTF into blocks (paragraphs, list items and nested tables), hyperlinks, footnotes, metadata and warnings, then renders Markdown or plain text from that model. The same module writes ASCII-only RTF from the model, and from the Markdown subset that its own renderer produces.

Last updated , the newest measurement or check on record for this page.

In Webfold's main branch; not in the 0.1.0 release

Status

  • The RTF reader, writer and Markdown-to-RTF converter are in Webfold's main branch: its superdocuments pin (68c216f) is past the old flat plain-text converter. Webfold built from main reads RTF with the structured reader (checked on a LibreOffice-written file on 2026-11-08). The 0.1.0 release predates it.

See it work

How do I try it from wherever I work?

1webfold convert letter.rtf

Input

What input does RTF reading accept?

RTF documents

The input must begin with {\rtf after optional spaces, tabs and line breaks. Anything else is an invalid-header error (superdocuments/src/rtf/reader.rs:144-162; model.rs:202-203).

Code pages

A run's code page comes from its font's \cpg, then the font's \fcharset, then the document's \ansicpg. Windows and CJK code pages go through encoding_rs (Shift_JIS 932, GBK 936, EUC-KR 949, Big5 950 and others). Mac Roman has its own table, and UTF-8 sequences split across \'hh escapes are joined (charset.rs:90-139, 143-172; reader.rs:895-911).

Unicode escapes and symbols

\uN is read as a signed 16-bit value. The following \ucN fallback characters are skipped. Surrogate pairs are joined. The Symbol font maps only the bullet and the ASCII punctuation range (reader.rs:849-876; charset.rs:20, 49-53).

Paragraphs, lists and tables

Paragraph marks (\par), page and section breaks, \line and \tab are handled. A paragraph with a \listtext marker or a \ls value is a list item, with level from \ilvl (reader.rs:457-459, 559-562, 759-762, 773-784). Tables use \trowd, \cellx, \cell, \row, \nestcell and \nestrow, with \intbl and \itapN for nesting and \trhdr for header rows (reader.rs:785-816, 1049-1191).

Headings

A paragraph is a heading when it has an outline level (\outlinelevel 0 to 8), when its style is named 'Heading N' or 'Title', or, with detect_headings on, when all its visible text is bold and at least 14 pt. At 18 pt or more the level is 1, and at 14 pt or more it is 2. The bold rule ignores text over 200 bytes (reader.rs:1200-1224, 1255-1297).

Hyperlinks and footnotes

A HYPERLINK field keeps its target, and an \l anchor is appended as #anchor (reader.rs:1319-1366). A \footnote becomes a note body numbered in order, and the body gets a [^n] reference (reader.rs:612-623; render.rs:64-76).

Metadata and optional parts

The \info group gives title, subject, author, keywords, comments, generator and creation and revision dates in YYYY-MM-DDTHH:MM (reader.rs:523-549, 1304-1316; model.rs:21-32). Headers and footers are read only when include_headers_footers is set (reader.rs:605-610; model.rs:176-197).

Output

What comes out?

RtfDocument

A public struct with metadata, blocks in reading order, hyperlinks, footnotes, optional header and footer parts, and warnings. Blocks are Paragraph (runs, heading level, alignment, indents), ListItem (level, ordered flag, marker, runs) and Table (rows of cells, each cell holding blocks). Runs carry bold, italic, underline, strike, font size in half-points, a link index and a footnote number (model.rs:5-19, 70-132).

Markdown from rtf::to_markdown or convert_bytes('rtf')

Headings as #, bold as **, italic as *, strike as ~~, links as [text](url), lists as - or N., tables as pipe tables with the first row as header (a table that holds another table is written as an HTML table, because a pipe table cannot nest), and footnote references as [^n]. Markdown characters in text are escaped (render.rs:51-95, 164-254, 298-397).

Plain text from rtf::to_text

Paragraphs and list items without markup. Table cells are joined with ' | ', or with tabs when TableStyle::Tabs is chosen (render.rs:100-148; model.rs:143-151).

ASCII RTF from rtf::to_rtf

Output is ASCII only. cp1252 characters are written as \'hh, and other characters as \uN? with \uc1. The writer adds a font table, a stylesheet with heading styles 1 to 6, the info group, and footnote bodies written inline (writer.rs:1-5, 13-17, 41-87, 220-264).

RTF from Markdown via rtf::markdown_to_rtf

Reads the Markdown subset that the renderer writes: ATX headings, paragraphs, bullet and numbered lists, pipe tables, bold, italic, strike, links and <br>. Code spans keep their text without backticks. Other syntax stays as literal text (markdown_in.rs:1-4, 11-92, 247-253).

Warnings

Damage that does not stop the read (unbalanced groups, unknown code pages, truncated escapes) is kept in RtfDocument::warnings, up to 200 stored warnings (reader.rs:16-17; model.rs:63-68; mod.rs:7-9).

How it works

What does the code do, step by step?

  1. 1

    Check the header and bounds

    Skip leading whitespace and require {\rtf. The input must be within 128 MiB (reader.rs:144-158).

  2. 2

    Tokenise and keep a group stack

    The lexer yields control words, symbols, hex bytes and \bin payloads. The interpreter keeps a stack of groups, each with its destination, character state, font, paragraph state and hyperlink (lexer.rs; reader.rs:19-105, 241-270).

  3. 3

    Choose each group's destination

    Body text, skipped destinations (pictures, the colour and list tables, unknown starred groups), font table, stylesheet, info, field instruction, list marker, footnote or header/footer part (reader.rs:440-630).

  4. 4

    Decode text

    Each byte run goes through the group's code page. A multi-byte decoder holds partial sequences until the next byte or a flush (reader.rs:913-964; charset.rs:195-300).

  5. 5

    Close blocks

    A paragraph mark, a cell end or a row end makes a block and places it in the current table level. Nesting follows \itap (reader.rs:1049-1158, 1163-1191).

  6. 6

    Render or write

    Headings and lists are decided from the paragraph's properties (reader.rs:1200-1224). rtf::to_markdown and rtf::to_text render the model; rtf::to_rtf writes it (render.rs; writer.rs:20-33).

Measured

What did we measure, and how?

RTF: Webfold against anydoc on 6 public fixtures

Run of 2026-09-24

How fast does Webfold convert these files to Markdown, in process and from a fresh process, against anydoc on the same bytes?

Corpus. 6 public fixtures in RTF from the anydoc repository (tests/fixtures; MIT licence per its README), out of 45 across ten formats. Fixture hashes: document-fixture-sha256-2026-09-24.txt.

ToolVersionRole
Webfoldgit 614483614e4e (document::convert_bytes, in process)Subject. The readers as of that revision, not the newer ones described on this page.
anydocgit 261fc257d17c (anydoc::to_markdown_bytes, Rust library)Comparison converter, linked into the same binary and given identical bytes.
MetricHow it is computed
Hot medianPer fixture: the median of 7 samples, each the mean time of 20 back-to-back in-process conversions of the same bytes, output consumed so it is not optimised away. The cell is the median of those per-fixture medians over the format's fixtures.
Cold medianPer fixture: the median of 7 timed launches (after 2 discarded) of a fresh runner process that reads the file and converts it once, with a warm operating-system cache. Wall time, process start included. The cell is the median of the per-fixture medians.
Converted without errorThe call returned a result. The run records conversion success and output size only; it has no gold text and no fidelity score.
FormatFixturesConverted without error, Webfold / anydocHot median, WebfoldHot median, anydocHot, anydoc over WebfoldWebfold faster, hotCold median, WebfoldCold median, anydocWebfold faster, cold
RTF6 (5 hand-made)6 / 65.2 µs94.6 µs18.2 times6 of 62.35 ms2.84 ms6 of 6
1# webfold-bench is private until the public release of Webfold. Run this from a checkout of it.
2git clone https://github.com/firecrawl/anydoc
3cd webfold-bench/bench/document-h2h
4cargo run --release -- --fixtures ../../../anydoc/tests/fixtures --out document-results.json --warmup 2 --samples 7 --iterations 20

Reproduce: webfold-bench/bench/document-h2h/src/main.rs; revisions and hashes: webfold-bench/bench/results/document-rust-cold-hot-2026-09-24.provenance.json. Figures read from webfold-bench/bench/results/document-rust-cold-hot-2026-09-24.json and document-fixture-sha256-2026-09-24.txt.

Limits of this measurement

  • Fixtures are tiny: 0.4 to 25 KB each, and most are hand-made feature probes from the anydoc repository, not real-world documents.
  • Speed on success only. Nothing here says the output is right: there is no gold text and no fidelity score, and a conversion that returns almost nothing still counts as converted.
  • One host (Intel Core i5-1340P, rustc 1.96.1) and one run, 2026-09-24. Hot timings are in process and exclude start-up; cold timings include process start, which dominates them (about 2 to 3 ms for both tools), so the cold gap is small.
  • The run measured the Webfold revision named above. The harness manifest pins a later Webfold revision (391c3e290b19) through git dependencies, so a rerun at the pin measures newer readers; to reproduce these figures, use the revisions listed in the provenance file. The readers written since the measured revision have not been benchmarked.

Document conversion benchmark →

Evidence

How do we know each claim?

Each row says how we know. Read from the code means the behaviour is in the implementation at the path shown. Covered by a test names a test in the repository; this page does not claim it was run for this release. Measured points to a benchmark with its protocol and limits.

ClaimHow we knowWhereDetail
The header must be {\rtf after optional whitespace; otherwise InvalidHeaderRead from the codesuperdocuments/src/rtf/reader.rs:144-158; model.rs:202-203read() skips spaces, tabs, CR and LF, then checks the prefix.
Defaults: 256 group levels, 128 MiB input, 128 MiB output; exceeding one is an errorRead from the codesuperdocuments/src/rtf/model.rs:155-172; reader.rs:146-151, 401-404, 1013-1017Input size and depth are checked directly; output size is checked in emit and push_run_with_style.
A run's code page is font cpg, then font fcharset, then the document ansicpgRead from the codesuperdocuments/src/rtf/reader.rs:895-911code_page() resolves in that order and caches the result per font.
Unsupported code pages (for example 437 and 850) warn and fall back to cp1252Read from the codesuperdocuments/src/rtf/reader.rs:880-893; charset.rs:58-87resolve_number() picks the message from is_known_unsupported() or reports an unknown page.
The Symbol font decodes only the bullet and the ASCII punctuation rangeRead from the codesuperdocuments/src/rtf/charset.rs:20, 49-53, 463Documented on the Kind::Symbol variant. Test symbol_font_bullet_and_unmapped at charset.rs:463, not run.
Headings come from outline level, the style name 'Heading N' or 'Title', or large bold textRead from the codesuperdocuments/src/rtf/reader.rs:1200-1224, 1255-1297heading_from_style_name() and large_bold_level() implement the three rules.
Markdown output escapes markup characters and writes a hard break for each newlineRead from the codesuperdocuments/src/rtf/render.rs:378-397escape_markdown() escapes backslash, backtick, asterisk, underscore, brackets, angle brackets and pipe, and writes backslash-newline for newline.
Writer output is ASCII: cp1252 as \'hh, other BMP characters as \uN? with \uc1, and it reads back with the same structure for every fixtureRead from the codesuperdocuments/src/rtf/writer.rs:1-5, 13-14, 230-264; superdocuments/src/rtf/tests.rs:513-525 (writer_output_is_ascii_and_reads_back_with_the_same_structure)non_ascii() and cp1252_byte() implement the escapes; the preamble declares \uc1. The named test is not run and compares a subset of the structure.
markdown_to_rtf parses only the Markdown subset the renderer writes; other syntax stays literalRead from the codesuperdocuments/src/rtf/markdown_in.rs:1-4, 11-92The module comment lists the supported syntax; unsupported text becomes a paragraph.
The structured reader replaces the flat RTF converter in superdocuments, and the revision Webfold pins has itRead from the codesuperdocuments/src/document.rs and src/rtf/ at the revision Webfold pins (68c216f)In superdocuments the rtf arm of convert_bytes is crate::rtf::convert. At 68c216f, the revision Webfold pins, the arm is present and src/rtf/ holds the reader, writer and Markdown renderer. Earlier revisions had a flat plain-text function instead. Checked on 2026-11-13.
Golden test: every fixture's Markdown equals its .expected.mdCovered by a testsuperdocuments/src/rtf/tests.rs:99-111 (golden_markdown_for_every_fixture)Named test; not run. The FIXTURES list covers all 23 .rtf files in tests/fixtures/rtf.
Depth, input size and output limits are explicit errors; truncated, mutated and random inputs do not panicCovered by a testsuperdocuments/src/rtf/tests.rs:438-480, 840-880 (exceeding_a_limit_is_an_explicit_error, truncated_and_mutated_fixtures_never_panic_or_hang, random_bytes_never_panic)Named tests; not run.

What are the limits?

Know the limits

  • Status: the reader, writer and markdown_to_rtf are in superdocuments, and the revision Webfold pins (68c216f) has them, so the Webfold command line and API read RTF with the structured reader. A later fix in that revision stops \* from hiding text outside a group start, keeps a \uN fallback from crossing a group end, and writes nested tables as HTML tables.
  • Dropped in Markdown output: colour (the colour table is skipped), font names (the font table keeps only charset and code page), underline (kept in the model, ignored by the renderer), and font size (kept in the model, used only for the heading rule). Right-to-left reordering is not implemented (CHANGELOG.md, RTF limits).
  • Pictures are dropped: \pict, \shppict and object data are skipped. An \object shows only its \result (reader.rs:580-583; 503-511).
  • Fields other than HYPERLINK keep their result text and lose the instruction (reader.rs:448-456).
  • Numbering without \listtext is shown as a '-' bullet in Markdown and text output (render.rs:113-117, 224). Ordered numbers appear only when the document supplies a marker.
  • The writer drops headers, footers and unreferenced footnotes, and writes every list with \ls1 and no list table (writer.rs:1-5, 132-147). A numbered list item with no stored marker is written with the marker '1.' (writer.rs:135-141).
  • The whole document is held in memory. There is no streaming reader (CHANGELOG.md, RTF limits).
  • Code pages that encoding_rs lacks (such as 437, 850, 1361) produce warnings and fall back to cp1252. Only the Symbol-font bullet is mapped (charset.rs:58-87; reader.rs:880-893).
  • Merged cells are read from the cell edges (\cellx), the way LibreOffice writes them: a cell that spans columns is one cell followed by empty ones. Checked on a LibreOffice-written file on 2026-11-09. Cells merged through \clmgf and \clmrg markers, and vertical merges, are not read.

Compared

How does it compare with other tools?

Compared withWhat the evidence saysSource
doclingDocling's supported-formats page lists RTF as an input that needs LibreOffice. The RTF reader here needs no external program.Docling supported formats
anydocanydoc's README lists rtf among its inputs, and Pandoc's manual and MinerU's README also list RTF. No RTF output comparison between any of them and Webfold has been run.anydoc README; Pandoc manual; MinerU README

Where should I read next?

FAQ

Frequently asked questions

Does RTF conversion need LibreOffice?

No. The reader is Rust, with encoding_rs for the code pages (superdocuments README dependency table). Docling's record says its RTF path needs LibreOffice.

Which RTF files are tested?

23 fixtures in tests/fixtures/rtf, each with an expected Markdown file. Their names cover Word, WordPad and LibreOffice exports, Cyrillic, Greek, Hebrew, Japanese, Chinese, Mac Roman, nested groups, footnotes, pictures and malformed inputs. The test harness is not run here.

Are tables kept?

Yes, including nested tables built with \intbl and \itapN, and header rows marked with \trhdr (reader.rs:785-816, 1163-1191). In Markdown output a table that holds a nested table comes out as an HTML table. The fixture tables-nested.rtf and a test named tables_nest_and_mark_the_header_row (tests.rs:202) cover this; not run.

Can I write RTF from Markdown?

Yes, for the Markdown subset the renderer produces: headings, paragraphs, lists, pipe tables, emphasis, strike, links and <br> (superdocuments/src/rtf/markdown_in.rs:1-4). Other syntax stays literal.

What happens with a broken file?

Unbalanced or truncated input keeps what can be read and adds warnings (RtfDocument::warnings). A bad header, or a value over a limit, returns an error (rtf/model.rs:200-210; rtf/mod.rs:7-9).

Read RTF with Webfold.

Run the command-line tool today, and join the waitlist for the hosted API.