ferrodoc
A universal document converter in Rust — markdown (CommonMark and GFM), HTML and DOCX — that produces the same output as pandoc, and produces it far faster.
ferrodoc vs pandoc
Every row measured on this machine in one sitting (Linux x86-64, pandoc 3.8.2.1, release build), reproducible with the commands below.
| pandoc | ferrodoc | ||
|---|---|---|---|
| Convert 16 KB markdown → HTML | 110 ms | 1.4 ms | 77× faster |
| Convert 0.6 KB markdown → HTML | 21 ms | 46 µs | 462× faster |
| Startup (one tiny document) | 13 ms | 2 ms | 6× faster |
| Peak memory, 0.6 KB document | 65 MB | 3.8 MB | 17× less |
| Peak memory, 16 KB document | 207 MB | 4.7 MB | 44× less |
| Binary on disk | 153 MB | 4.5 MB | 34× smaller |
| Malformed DOCX (self-referential footnote) | hangs, killed at 60 s | handled in 12 ms | — |
| Same document written twice | different bytes | identical bytes | — |
| Runs in a browser / edge worker | no | yes (wasm32) | — |
DOCX, in process: writes the 16 KB document in 1.7 ms and reads it back in 5.0 ms (0.22 ms / 0.34 ms for the small one) — a path pandoc can only offer through a subprocess.
The throughput rows compare an in-process library call against a pandoc
subprocess, because that is the choice a real pipeline makes: pandoc is a
binary, so calling it from a program means spawning a process per document.
The small-document ratio is dominated by that startup — precisely the cost
document pipelines pay on every file today.
Absolute timings move with CPU state; the ratios hold. Compare interleaved runs, never numbers from different sittings.
And the output is the same
Speed is worthless if the document changes. Every claim here comes from running the real pandoc binary side by side and comparing whole documents — nothing is trusted because it looks right.
| crate | conformance vs pandoc 3.8.2.1 |
|---|---|
ferrodoc-ast |
any pandoc -t json document round-trips to an equal value |
ferrodoc-markdown |
652/652 CommonMark spec examples produce identical ASTs |
ferrodoc-markdown GFM reader |
654/655 documents produce identical ASTs |
ferrodoc-html |
652/652 spec examples produce identical HTML |
ferrodoc-docx reader |
36/37 corpus documents produce identical ASTs |
ferrodoc-docx writer |
643/652 spec examples survive a DOCX round trip identically, with embedded images and document metadata |
ferrodoc-markdown writer |
652/652 spec examples survive a markdown round trip identically (pandoc: 593/652) |
ferrodoc-markdown GFM writer |
655/655 documents survive a GFM round trip identically (pandoc: 589/655) |
ferrodoc-html reader |
633/659 HTML documents produce identical ASTs |
diff-write is the DOCX writer's oracle: both engines write the same AST to
a .docx, pandoc reads both back, and the two documents must match.
Comparing zip bytes would be meaningless; comparing what the format
preserves is the real contract.
diff-md measures the markdown writer by fidelity — write the document out,
read it back, require what returns to be what went in — and prints pandoc's
score on the same corpus beside ours. This is the one place ferrodoc is
measurably ahead rather than equal: 652/652 against pandoc's 593/652,
with pandoc given its best setting (--wrap=preserve; --wrap=none would
cost it another 58 by flattening soft breaks). Pandoc loses escaped
punctuation, autolinks containing backslashes and code spans with edge
spaces; converting a DOCX to markdown and back changes those documents.
The remaining gaps are limits of CommonMark itself and are listed in
crates/ferrodoc-markdown/src/write.rs.
Install and use
Or take a prebuilt binary from the latest release — Linux (musl), macOS (Intel and Apple silicon), Windows, and a wasm32 package — or add the library to a project:
[]
= "0.1"
|
Inputs: markdown (commonmark, md), gfm, html, docx, json (the
pandoc AST). Outputs: those plus plain. -s/--standalone wraps HTML
output in a complete page — doctype, charset, lang, and the title and
authors the document carries — and --css FILE inlines a stylesheet into
it.
Prefer -t gfm over -t markdown for anything with a table: CommonMark has
no table syntax, so a table degrades to one paragraph per cell there.
As a library, one call converts — and the AST is right there when you want to transform rather than convert, with no subprocess and no JSON round trip:
use ;
let html = convert?;
let mut doc = parse?;
doc.blocks.retain;
let without_headings = render?;
Why the differences are structural, not incidental
- No runtime. Memory tracks the document, not an interpreter; there is no GC pause and nothing to page in before work starts.
- A library, not a binary. Callers get typed values —
Pandoc,Block,Inline— to inspect and transform in memory. - Bounded and memory-safe. Every recursive path is depth-limited and
readers return
Errrather than aborting;unsafeis forbidden crate-wide, so a malformed document cannot become a memory-safety bug. Two fuzz-found DOCX files that hang pandoc for 60 s are handled here in 12 ms and 3.5 s. - Deterministic output. The same AST always produces the same
.docxbytes, which makes content-addressed caching and "did this change?" work. Pandoc embeds timestamps, so its output differs run to run. - Portable. All five library crates, including the DOCX reader and writer,
compile to
wasm32-unknown-unknown— conversion in a browser tab or an edge worker, with no document leaving the client.
Every gate, every known loss and every deliberate divergence is listed one
by one in COMPATIBILITY.md, with the command that
produces it. CI runs all nine against a pinned pandoc, and builds and tests on
Linux, macOS and Windows, plus a wasm32 build and a 500,000-mutation fuzz
campaign.
Where pandoc is still ahead
The table above is not the whole picture, and pretending otherwise would make
the rest less believable. Pandoc supports ~40 formats to ferrodoc's four, plus
citations, templates, Lua filters, PDF output and fifteen years of edge cases.
Our DOCX writer still drops raw blocks, which have no OOXML equivalent, and
embeds eight image formats — PNG, JPEG, GIF, WebP, TIFF, SVG, EMF and WMF —
where anything else becomes alt text. The HTML reader parses to the HTML5
spec, via html5ever, where pandoc uses tagsoup; on malformed markup — an
unclosed <a>, a tag with no closing >, a <pre> inside a <tr> — the
two build different trees, and that is what most of the 26 unmatched
documents are. Not all: <![CDATA[…]]> tokenizes differently, and <a/>
self-closing syntax sends the two down different recovery paths. The
remaining families are listed in TODO.md, one by one. The bet is not that
this replaces pandoc — it is that the common path, markdown/HTML/DOCX
called from a program rather than a shell, is worth doing natively.
License notes
corpus/commonmark-spec-0.31.2.json is the example set from the
CommonMark spec, © John MacFarlane,
licensed CC-BY-SA 4.0.