html-to-markdown: One Rust Core Behind Sixteen Language Bindings
High performance and CommonMark compliant HTML to Markdown converter. Maintained by the Kreuzberg team. Kreuzberg is a fast, polyglot document intelligence engine with a Rust core. It extracts structured data from 98+ document formats using streaming parsers and built-in OCR.
At a glance
- What is it?
- The Kreuzberg team's converter aims at messy real-world HTML, returns CommonMark or Djot from a single convert() call, and ships bindings for sixteen languages. The tiered dispatch design is the interesting part, and the per-tier byte-equality claim is the thing to verify before you commit.
- Who is it for?
- Adopt it if you already parse HTML somewhere in a pipeline and want one converter across several languages, or if your inputs are genuinely messy: unclosed tags, CDATA, custom elements, nested tables. Do not adopt it if you need a stable AST to inspect and rewrite before serialising, because the Visitor API is feature-gated and the default path is HTML in, string out.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly HTML, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 15, 2026, and from our analysis. They are not legal advice.
DEEP OPEN-SOURCE ANALYSIS
The input problem is malformed HTML, not conversion
Turning well-formed HTML into Markdown is a solved problem with a dozen small libraries. The hard part is that the HTML you actually have is not well-formed. The README lists the specific failures it targets: unclosed tags, CDATA sections, custom elements, malformed entities, nested tables, and mixed encodings. Those are the inputs that make a naive regex or a strict XML parser drop content silently, which is worse than failing loudly because you only notice when a downstream document is missing a paragraph.
The audience follows from that list. Anyone building a retrieval pipeline over scraped pages, archived CMS exports, or hOCR output from OCR runs will recognise the shape of the problem. The repository's own topics include rag, text-extraction, and hocr, which tells you where the maintainers expect it to land. If your HTML comes from a generator you control and is already valid, you are paying for repair machinery you will never exercise.
Tiered dispatch: a byte scanner, a DOM walker, then html5ever
The mechanism the README names is tiered dispatch. Three tiers are listed in order: a byte scanner, a DOM walker, and html5ever repair. The claim attached to it is byte-equal output across tiers.
Read that as a fast path with fallbacks. A byte scanner can handle simple, predictable markup without building a tree at all. When the input stops being predictable, the converter moves to walking a DOM. When the markup is broken enough that a tree cannot be built correctly, it hands off to html5ever, the Rust HTML5 parser, for repair. The point of the ordering is that you do not choose. The README states plainly that the messy-input handling is automatic and that you never select a parsing strategy.
Byte-equal output across tiers is a strong claim and a testable one. It means the same document should produce the same Markdown whether it took the scanner path or the repair path. That is the property that makes the tiering safe to ignore, and it is also the property most likely to break first when a new edge case lands in the scanner. The README mentions per-group regression thresholds enforced on every PR, which suggests the equality is checked rather than asserted, but the material does not describe the test corpus beyond naming Wikipedia and mdream.
Sixteen bindings over one core, and what that does not guarantee
The bindings are Rust, Python, Node.js, WASM, Java, Go, C#, PHP, Ruby, Elixir, R, Dart, Kotlin for Android, Swift, Zig, and a C ABI. The Quick Start shows the Rust install as cargo add html-to-markdown-rs, and the badge links point at crates.io, PyPI, npm under @xberg-io/html-to-markdown and @xberg-io/html-to-markdown-wasm, Maven Central, pkg.go.dev, NuGet, Packagist, RubyGems, Hex, r-universe, pub.dev, and a separate Maven artifact for Android. Swift and Zig are listed as directories inside the repository's packages folder rather than as registry packages.
One core in Rust means the parsing behaviour should be identical everywhere, which is the actual selling point: the same document converted in Go and in Python should not diverge. What the binding list does not guarantee is API parity. Bindings are separate packages with separate release cadences, and the README does not publish a compatibility matrix. The version numbers in the badge set are per-registry, so a Python package at one version and a Ruby gem at another is normal rather than a fault. If you depend on the metadata extraction or the Visitor API, check that your language's binding exposes it before you plan around it.
Getting it running: convert(), output_format, and the presets
The single entry point is convert(). The README describes it as returning a structured result with content, warnings, and optional metadata. That shape matters for integration: warnings give you a hook to log degraded parses instead of guessing, and metadata is opt-in in the sense that it is optional on the result.
Two configuration keys appear in the material. output_format takes the string "djot" to emit Djot instead of Markdown. Preprocessing has three named presets: standard, strict, and lenient, and the README says you can build your own. Those presets control how aggressively the input is normalised before conversion, which is the knob you reach for when strict parsing rejects something you know is fine, or when lenient parsing is flattening structure you wanted kept.
Beyond that, the README is thin on configuration. It does not list the full key set, the defaults, or the exact contents of each preset. The homepage at docs.html-to-markdown.xberg.io is the place that presumably carries the reference, and there is a live WebAssembly demo linked from the badges. Anyone evaluating this should read the docs site rather than the README for the config surface.
Metadata, tables, and images are separate extraction concerns
Three features sit alongside plain conversion and are worth separating in your head.
Metadata extraction parses the head element into structured data covering Open Graph, Twitter cards, JSON-LD, microdata, RDFa, and a header hierarchy. That is a different job from converting body content, and bundling it saves a second parse of the same document.
GFM tables get padded cells, alignment, and pipe escaping. The README specifically calls out nested tables as an input it handles, which is the case where table conversion usually produces garbage.
Inline images are opt-in and described as mirroring data URIs and remote image references. Opt-in is the right default here, because fetching remote images during conversion turns a pure function into one with network dependencies and a latency profile you did not ask for.
The Visitor API is feature-gated and lets you traverse and transform the converted Markdown AST. Feature-gated is the important word. If you need to rewrite output structurally, confirm the feature is compiled into the binding you are using, because a default build will not have it.
Where it is the wrong tool, and what to use instead
The README's performance claim is 19 to 116 MB/s on the Wikipedia and mdream corpus. That is a wide band, and the material does not say which tier or which input mix produces which end of it. Treat the range as an upper bound on throughput rather than a number you can plan capacity around.
The clearer limitation is the output contract. This is HTML in, Markdown or Djot string out. If your pipeline needs a document model you can query, edit, and re-serialise with full fidelity, a converter that returns a string is the wrong layer. Turndown is the obvious alternative in the JavaScript ecosystem, and the difference in approach is structural: Turndown works on a DOM you supply, typically from a browser or a DOM implementation, and lets you register custom rules per node type. That gives you fine control over how a specific element becomes Markdown, at the cost of you owning the DOM and the parsing. html-to-markdown owns the parsing and the repair, and in exchange you get a string plus warnings rather than a node-by-node rule system. Neither is better in the abstract. Pick by asking whether your customisation lives in the tree or in the text.
The second boundary is Djot. The README presents it as a flag, not as a second-class path, but if you need output formats beyond Markdown and Djot, the material lists no others.
Maintenance cost and the MIT licence
The repository is not archived and the last push in the supplied data is dated 2026-09-10, with three releases in the days before it: v3.12.3 on 2026-09-09, v3.12.2 on 2026-09-07, and v3.12.1 earlier the same day. That cadence suggests active patch-level maintenance, and the version numbering suggests the public API is settled at major version 3.
Sixteen bindings is the real maintenance cost, and it is the maintainers' cost more than yours until you need a fix in a binding that lags. The practical exposure for an adopter is upgrade friction: a change in the Rust core has to propagate through every package, and the release timestamps show that propagation is not always simultaneous. If you pin a binding, pin it deliberately and check the corresponding core version.
The licence is MIT. That is permissive and short, which means it imposes few obligations beyond preserving the copyright notice and permission text. This is not legal advice; read the LICENSE file in the repository and, if your organisation has a policy on permissive licences, run it through that process. MIT is compatible with proprietary use, which is usually the question people are actually asking.
One note on the repository itself: the README is auto-generated by a tool called alef, with a hash comment at the top and instructions to regenerate with alef readme and verify freshness with alef verify. That means the README you read is generated from source metadata, and edits to it directly will be overwritten. If you are contributing documentation, that is where to look first.
Editorial conclusion
Adopt it if you already parse HTML somewhere in a pipeline and want one converter across several languages, or if your inputs are genuinely messy: unclosed tags, CDATA, custom elements, nested tables. Do not adopt it if you need a stable AST to inspect and rewrite before serialising, because the Visitor API is feature-gated and the default path is HTML in, string out. Verify three things first: that your target language's package name exists on its registry, that the tier your input falls into actually produces the output you expect, and that Djot is the only output format you need beyond CommonMark, since the README lists no third option.
Community notes