Webfold
Menu

Format · DOCX

Word files (DOCX, DOCM) to Markdown: headings from styles, lists, links, tables, footnotes.

Webfold turns DOCX and DOCM files into Markdown with headings from the document's own styles, lists, links, tables (merged and nested cells included) and footnotes: on 11 DOCX fixtures it converted all 11 without error at a hot median of 38.1 µs against 155.5 µs for anydoc, another document converter, measured on 24 September before the rewrite; images, headers, footers and comments are not read, and the rewritten reader is in the main branch, not in the 0.1.0 release.

Reads a DOCX or DOCM package and writes Markdown: headings taken from the document's own styles, bullet and numbered lists, bold and italic, hyperlinks with their targets, pipe tables (merged cells and nested tables included), and footnotes and endnotes. Tracked deletions are left out. Images are not extracted, and headers, footers and comments are not read.

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 rewritten DOCX, PPTX and XLSX readers are in Webfold's main branch. Webfold pins superdocuments at revision 68c216f, which contains them. Webfold built from main reads files this way; the 0.1.0 release predates it. Everything below describes the new readers.

See it work

How do I try it from wherever I work?

1webfold convert report.docx

Input

What input does DOCX reading accept?

DOCX and DOCM packages

One reader serves both extensions (superdocuments/src/ooxml/docx.rs). The main part is the officeDocument relationship in _rels/.rels, else word/document.xml; a target that leaves the package is an error. Elements and attributes are matched by local name, so a file whose producer binds the WordprocessingML namespace to some other prefix reads the same.

Headings from styles

styles.xml decides. A paragraph is a heading when its style, or a style it is based on, has an outline level, or is named 'heading N' or 'Title'. A paragraph's own outline level overrides its style, and level 9 means body text. Depth is capped at six '#', and Title is depth one.

Lists

A numPr on the paragraph or on its style points into numbering.xml. A bullet level gives '- '. Any other number format gives '1.', counted per list and level, and a level numbered 'none' is a plain paragraph. Nested levels are indented four spaces.

Bold, italic and links

Run properties w:b and w:i (a value of 0 or false turns them off) and the Strong and Emphasis character styles give ** and *. Hyperlinks become [text](url), from relationships and from HYPERLINK field instructions. A link that only names a bookmark keeps its text and drops the link.

Tables

Each table is a pipe table whose first row is the header. A gridSpan adds empty cells for the columns it covers, and a vertically merged continuation cell is empty. Paragraphs and soft line breaks inside a cell are joined with <br>. A nested table is flattened into its parent cell, one line per row, with the cells joined by ' · '.

Footnotes and endnotes

A reference becomes [^id] (endnotes [^eN]). The notes that the body refers to are written as [^id]: definitions after the body, in order of first reference. Separator notes are skipped.

Text boxes and alternate content

For mc:AlternateContent only the Choice branch is read, so a text box that Word also stores as a VML fallback is read once. The paragraphs of a text box are written as their own paragraphs, ahead of the paragraph that holds the box.

Tracked changes

Inserted text is kept and deleted text (w:delText) is not. The records that hold a paragraph's or run's previous properties (pPrChange, rPrChange and the like) are skipped, so old properties never replace the current ones.

Alt text, embedded objects, charts and diagrams

The descr attribute of wp:docPr is written as text. An embedded OLE object is written as 'Embedded object:' and its ProgID, except an embedded picture (Word.Picture.8, Paint.Picture, PBrush), which is written as its image only. After the body, every word/charts part gives its title and values and every word/diagrams part gives its text. No image bytes are read.

Output

What comes out?

Markdown string

Blocks are separated by blank lines. Headings are '#' lines, consecutive list items sit on consecutive lines, tables are pipe tables, footnote definitions follow the body, and chart and diagram text comes last. The output is trimmed at both ends.

One output on every path

convert_streaming and DocumentCache::convert call the same reader as convert_bytes. The separate streaming writer and cache-lift code that DOCX used to have, which gave different Markdown for the same file, were removed.

Table shape

Every row is padded with empty cells to the widest row. A table with no rows writes nothing.

How it works

What does the code do, step by step?

  1. 1

    Dispatch

    convert_bytes sends docx and docm to ooxml::docx::convert. The Webfold command line sends .docx and .docm to webfold's convert_path, which calls convert_bytes in the superdocuments revision Webfold pins (68c216f).

  2. 2

    Bound the archive

    The ZIP is opened only after its entry count (at most 4,096), each declared entry size (at most 32 MiB) and the declared total (at most 128 MiB) pass. Entry reads are capped at the limit plus one byte whatever the declared size.

  3. 3

    Find the parts

    The main part comes from _rels/.rels. Its own relationships give the styles, numbering, footnotes and endnotes parts and the hyperlink targets. Relationship targets are resolved against the part that holds them and may not leave the package.

  4. 4

    Read styles and numbering

    styles.xml gives each style's name, outline level, list and parent; numbering.xml gives each list's number format per level.

  5. 5

    Read the body as events

    A paragraph stack and a table stack track nesting, so a text box inside a paragraph, or a table inside a cell, cannot close the wrong thing. A paragraph ends as a heading, a list item or a plain paragraph, and goes to the current table cell if one is open.

  6. 6

    Read the notes parts

    footnotes.xml and endnotes.xml are read with the same code, each with its own hyperlink relationships. Only notes the body refers to are written.

  7. 7

    Append chart and diagram text

    Each word/charts and word/diagrams part is read for its text and appended after the body.

Measured

What did we measure, and how?

DOCX: Webfold against anydoc on 11 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. 11 public fixtures in DOCX 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
DOCX11 (10 hand-made)11 / 1138.1 µs155.5 µs4.1 times11 of 112.74 ms3.18 ms9 of 11
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 →

Command-line calls per second against Pandoc and MarkItDown, on a pool that includes DOCX

Run of 2026-09-25

How many one-document command-line conversions per second does each tool complete when several run at once?

Corpus. A pool of 17 public fixtures from the anydoc repository (tests/fixtures, MIT licence per its README), CSV, XLSX and DOCX only and not split by format, 20 calls per worker, one process per call.

ToolVersionRole
Webfold CLIwebfold convert <file>, binary built from the Webfold revision recorded in the run's provenance fileSubject.
Pandoc3.1.11.1 (pandoc -f <format> -t markdown)Comparison. This version has no xlsx reader, so its XLSX calls fail and are counted as errors.
MarkItDown0.1.8 (markitdown <file>, with the docx, xlsx, pdf and all extras)Comparison. Starts a Python interpreter for each call.
MetricHow it is computed
Calls per secondDocuments completed across all workers divided by the wall time of the run. A failed call counts as a call.
ConcurrencyWebfold CLIPandoc 3.1.11.1MarkItDown 0.1.8
1 worker332.639.2 (5 of 18 calls failed)0.44
4 workers739.5159.1 (20 of 72 calls failed)1.35
16 workers2,172.8329.0 (80 of 288 calls failed)2.80
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-vs-incumbents
4cargo run --release -- --fixtures ../../../anydoc/tests/fixtures --out document-vs-incumbents.json

Reproduce: webfold-bench/bench/document-vs-incumbents/src/main.rs; protocol: webfold-bench/bench/results/document-vs-incumbents-2026-09-25.provenance.json. Figures read from webfold-bench/bench/results/document-vs-incumbents-2026-09-25.json and .provenance.json.

Limits of this measurement

  • The pool is CSV, XLSX and DOCX only, and the figures are not split by format, so this is not a DOCX-only or spreadsheet-only number.
  • Pandoc 3.1.11.1 has no xlsx reader: the XLSX files in the pool failed for it, and those failed calls are inside its calls per second. At 16 workers, 80 of its 288 calls failed.
  • Throughput counts calls, not correct output: there is no fidelity score. Peak memory for the subprocess tools includes the child process.
  • One host and one run. The harness does not record the CPU model in the result file. The manifest pins Webfold through git dependencies; use the revisions in the provenance file to reproduce.

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
Headings, lists, bold and italic, and tables convert from a python-docx fileCovered by a testsuperdocuments/tests/office_real.rs (docx_structure_comes_from_styles_numbering_and_tables); fixture tests/fixtures/office/basic.docxRan 2026-11-08, passing. Title and Heading 1 and 2 give '#', '#' and '##'; 'List Bullet' and 'List Number' give '- ' and '1. ' through numbering.xml; a cell with a pipe and a soft break gives 'Value \| pipe' and 'Line one<br>Line two'.
Entity references (&amp; &lt; &gt;) in text survive in DOCXCovered by a testsuperdocuments/tests/office_real.rs (entity_references_are_kept_in_every_xml_format)Ran 2026-11-08, passing. Before commit 44241df every XML reader here dropped the character an entity reference stands for.
Any namespace prefix reads; outline level; tracked deletions; alternate content; nested tables; gridSpan and vMerge; HYPERLINK fields; w:b val=0; Title without a styles partCovered by a testsuperdocuments/src/ooxml/docx.rs (module tests)Ran 2026-11-08, 9 tests passing. They use XML built by hand in the test, not a file from Word.
A heading is decided by outline level or style name along the basedOn chain, capped at sixRead from the codesuperdocuments/src/ooxml/docx.rs (Styles::heading_level, name_level)A direct outlineLvl wins; level 9 gives body text; Title is depth one.
A bullet level gives '- ', other formats give a counted '1.', format none gives plain textRead from the codesuperdocuments/src/ooxml/docx.rs (Numbering::is_bullet, Parser::finish_paragraph)The count restarts when a shallower level of the same list appears.
Only the Choice half of mc:AlternateContent is readRead from the codesuperdocuments/src/ooxml/docx.rs (Parser::start, skip counter)Fallback and the property-change records are skipped with the same counter.
convert_streaming and the cache use the same DOCX reader as convert_bytesRead from the codesuperdocuments/src/document.rs (convert_streaming fallback arm; DocFormat::is_streaming)The earlier write_docx_streaming, lift_docx and render_docx were deleted. The equality test convert_streaming_matches_convert_bytes_for_csv_docx_xlsx still passes.
ZIP bounds: 4,096 entries, 32 MiB per declared entry, 128 MiB declared totalRead from the codesuperdocuments/src/document.rs (archive, entry)Unchanged by the rewrite.
A relationship target is resolved inside the package; one that leaves it is refusedCovered by a testsuperdocuments/src/ooxml/mod.rs (resolve_joins_against_the_holding_part_and_stays_inside)Ran 2026-11-08, passing.
Routing: the Webfold command line sends docx and docm to the superdocuments conversionRead from the codewebfold/src/cli/document.rs:130-140markdown_source lists docx and docm in the document branch and calls webfold::document::convert_path, which uses the superdocuments revision Webfold pins (68c216f).
Webfold converts mixed office and ebook fixtures in about 27.7 microseconds median on one document when warm and in processMeasured/benchmarks/documents/ ; webfold-bench/bench/results/document-rust-cold-hot-2026-09-24.json45 public fixtures across formats, uncached, measured with the older readers. The new readers have not been benchmarked, and the figure is not a DOCX number. From a fresh process the median is 2.77 ms. Measured with the older readers; the new ones have not been benchmarked.

What are the limits?

Know the limits

  • No file written by Microsoft Word is among the test fixtures. The real files come from python-docx (which uses Word's default style set) and LibreOffice; the rest is XML built by hand. A Word file with unusual styles or numbering may read differently.
  • List numbering restarts and start values are not applied: a numbered list counts from 1 per list and level, whatever its start override says. Bullet glyphs are not read.
  • Underline, strikethrough, colour, superscript and subscript are dropped. Page, column and section breaks are ignored. Markdown characters in the text (asterisks, underscores, brackets) are not escaped, except pipes in table cells.
  • Headers, footers and comments are not read. Embedded pictures are written as !alt at the drawing's position, and document::extract_images returns their bytes, type, dimensions and alt text. Chart and SmartArt text is appended after the body, not placed where the object sits.
  • Markdown has no spans: a merged cell leaves empty cells at the covered positions, and a nested table is flattened to text inside its parent cell.
  • Equations are read as their plain text runs (m:t), without structure.
  • Text box paragraphs are written ahead of the paragraph that holds the box, not at the box's position in the line.
  • Legacy .doc (Word 97 to 2003) is read as text only, with tables: no headings, lists, headers, footers, footnotes or text boxes, and an encrypted file is refused. A .doc file that is really a DOCX or RTF file is read by its content.

Compared

How does it compare with other tools?

Compared withWhat the evidence saysSource
pandocPandoc's manual lists docx among its input formats and as an output format, and lists epub, odt, pptx, rtf and xlsx as inputs. Fidelity was scored against Pandoc 3.1.3's own reading of 50 DOCX files (mean of structure, features and words): 0.934 for superdocuments before the Pandoc-compatibility work, 0.972 after it, and 0.924 for MarkItDown 0.1.9. These scores are for superdocuments main. Part of the work behind them is in the revision Webfold pins and the rest, the Pandoc layout, came after it, so the Webfold command line has not been scored. A difference remains where Pandoc turns list items with a hanging indent into block quotes and we keep them as list items. The command-line throughput run over CSV, XLSX and DOCX files is in the measured section above.Pandoc manual
doclingDocling's supported-formats page lists DOCX, XLSX and PPTX as inputs without a LibreOffice note, and lists DOC, XLS, PPT and RTF as needing LibreOffice.Docling supported formats
anydocanydoc's README lists doc, docx, odt, rtf, epub, pdf, ppt/pptx, xls/xlsx/xlsm, ods/odp and csv among its inputs. We timed both on the same 45 public fixtures, 11 of them DOCX (measured section above). That run scores conversion success and output size only, not DOCX fidelity, and anydoc's own README benchmark uses a corpus that is not in its repository.anydoc README; Document conversion benchmark
markerMarker's README lists DOCX and PPTX among its inputs and writes Markdown, JSON, chunks and HTML. It needs PyTorch, and its model weights are under a modified OpenRAIL-M licence that is free for research, personal use and startups under 5 million dollars in funding or revenue.marker README; marker LICENSE

Where should I read next?

FAQ

Frequently asked questions

Are images extracted from a DOCX?

No. Image parts are never opened. The drawing's alt text (the descr attribute of wp:docPr) is written as text in the paragraph that holds the drawing.

Why does a paragraph in a custom style come out as a heading?

Headings follow the style's outline level and its basedOn chain, not only its name, so a style based on Heading 1, or with an outline level set, is a heading. Set the paragraph's outline level to 'Body Text' in Word to make it plain.

Are lists kept?

Yes. A paragraph in a list, by its own numPr or its style's, becomes '- ' or a counted '1.' with nested levels indented four spaces. Start overrides are not applied.

Are footnotes and endnotes kept?

Yes. References become [^id] and the notes the body refers to are written as definitions after the body.

Are tracked changes shown?

Insertions are kept as ordinary text and deletions are left out. Who changed what, and comments, are not written.

Does the Webfold command line do this today?

Yes, in Webfold's main branch: it pins a superdocuments revision (68c216f) that has the new readers. The 0.1.0 release predates it.

Read DOCX with Webfold.

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