markdownify-rs
Rust implementation of Python markdownify with output parity as the primary goal.
Python bindings
Build and install locally with maturin (uv):
uv venv
uv pip install maturin
.venv/bin/maturin develop --features python
Build via pip (PEP 517):
uv pip install .
Usage:
from markdownify_rs import markdownify
print(markdownify("<b>Hello</b>"))
BS4-style read-only HTML API:
from markdownify_rs import Soup
soup = Soup("<div class='main'><p>Hello</p></div>")
p = soup.find("p")
print(p.get_text()) # "Hello"
print(soup.select("div.main > p")) # [<Tag <p>Hello</p>>]
print(p.parent.name) # "div"
BS4 batch query API (GIL-free execution in Rust):
from markdownify_rs import QueryPlan, run_batch
plan = QueryPlan()
plan.add_select_count("links", "a[href]")
plan.add_select_one_text("title", "title")
plan.add_select_all_texts("link_texts", "a", separator=" ", strip=True)
plan.add_get_text("all_text", " ", True)
rows = run_batch(["<html><title>A</title><a href='/x'>x</a></html>"], plan)
print(rows[0]["links"], rows[0]["title"], rows[0]["link_texts"]) # 1, "A", ["x"]
Full Python bs4 API + QueryPlan guide:
PYTHON_BS4_API.md
Markdown -> HTML usage (Python-Markdown-style API):
from markdownify_rs import markdown, Markdown
print(markdown("# Hello", extensions=["tables", "footnotes"]))
md = Markdown(extensions=["toc", "admonition"])
print(md.convert("[TOC]\n\n# Title"))
print(md.toc_tokens) # [{"level": 1, "id": "...", "name": "Title"}]
# Optional CommonMark/GFM-ish toggles (default False):
print(markdown("visit https://example.com", autolink=True))
print(markdown("- [ ] task", tasklist=True))
print(markdown("~~old~~", strikethrough=True))
# Parsing mode:
# - python_compat (default): best-effort Python-Markdown compatibility
# - fast: pure comrak/CommonMark fast path
print(markdown("1) one\n2) two", extensions=["sane_lists"], mode="python_compat"))
print(markdown("1) one\n2) two", extensions=["sane_lists"], mode="fast"))
Full Python markdown-to-HTML API quickstart (all public args):
PYTHON_MARKDOWN_TO_HTML_QUICKSTART.md
Batch usage (parallelized in Rust):
from markdownify_rs import markdownify_batch
outputs = markdownify_batch(["<b>Hello</b>", "<i>World</i>"])
Markdown-adjacent utilities (submodule):
from markdownify_rs.markdown_utils import (
split_into_chunks,
split_into_chunks_batch,
coalesce_small_chunks,
link_percentage,
link_percentage_batch,
filter_by_link_percentage,
strip_links_with_substring,
strip_links_with_substring_batch,
remove_large_tables,
remove_large_tables_batch,
remove_lines_with_substring,
remove_lines_with_substring_batch,
fix_newlines,
fix_newlines_batch,
split_on_dividers,
strip_html_and_contents,
strip_html_and_contents_batch,
strip_data_uri_images,
text_pipeline_batch,
)
chunks = split_into_chunks(text, how="sections")
chunks_batch = split_into_chunks_batch([text1, text2], how="sections")
cleaned = strip_links_with_substring(text, "javascript")
cleaned_batch = strip_links_with_substring_batch([text1, text2], "javascript")
filtered = filter_by_link_percentage([text1, text2], threshold=0.5)
pipelined = text_pipeline_batch(
[text1, text2],
steps=[
("strip_links_with_substring", {"substring": "javascript"}),
("remove_large_tables", {"max_cells": 200}),
("fix_newlines", {}),
],
)
Notes:
code_language_callbackis not yet supported in the Python bindings.
CLI:
markdownify-rs input.html
cat input.html | markdownify-rs
Releasing
Releases are built and uploaded locally; there is no CI publish job.
- Bump
versionin bothCargo.tomlandpyproject.toml. - Build and smoke-test everything into
dist/(needs Docker, e.g.colima start):scripts/build_release_wheels.sh
This builds an sdist, a macOS universal2 wheel, and manylinux2014 aarch64/x86_64 wheels (abi3, CPython >= 3.8), then checks that all three wheels produce identical output. - Upload (prompts for a PyPI token, or set
UV_PUBLISH_TOKEN):uv publish dist/*
HTML parser
By default, markdownify and Soup parse with a vendored copy of the
astral-html tokenizer (src/astral_html/,
MIT/Apache-2.0) and build a scraper::Html tree from its tokens (src/astral_tree.rs).
Nesting is lexical like BeautifulSoup’s html.parser (an end tag closes the innermost
matching open element; nothing is implied or relocated), which is both faster than
html5ever’s tree builder and closer to python-markdownify’s output (e.g. <noscript>
contents are parsed as markup). Build with --no-default-features to use
scraper/html5ever instead. xml_utils::get_tag always uses scraper for its fallback.
Parity hacks (vs. BeautifulSoup)
These are explicit, ad hoc behaviors added to match python-markdownify
(BeautifulSoup + html.parser) output. They are intentionally quirky and may be
replaced with more “correct” behavior once parity is stable.
- Leading whitespace reconstruction: html.parser preserves whitespace‑only text
nodes that html5ever drops (notably between
<html>children and at the start of<body>). We reconstruct the normalized leading whitespace prefix (using the same “single space vs. single newline” rules as BeautifulSoup’sendData) and merge it with the converter output, carrying it across non‑block tags and empty custom elements whose contents are only comments/whitespace. - Table header inference: For tables whose header row is effectively empty, we avoid forcing a “---” separator to match python-markdownify behavior.
- Top-level
<td>/<th>wrapping: If input is a bare<td>/<th>, we wrap it in a<table><tr>…</tr></table>fragment to align with python-markdownify output.
Benchmarks
Datasets
- Michigan Statutes (JSONL, 241 HTML documents).
- Total HTML bytes: 101,029,525 (~96.35 MiB).
- Largest document: 8,034,686 bytes (~7.66 MiB).
- Source file size: 102,856,616 bytes (~98.10 MiB).
- Law websites (CSV, 3,136 HTML documents).
- Total HTML bytes: 111,747,114 (~106.57 MiB).
- Largest document: 1,381,380 bytes (~1.32 MiB).
- Source file size: 148,486,852 bytes (~141.61 MiB).
Run
# Michigan Statutes (JSONL)
MARKDOWNIFY_BENCH_PATH=/path/to/mi_statutes.jsonl .venv/bin/python scripts/bench_python.py --module markdownify_rs --dist-name markdownify-rs --label markdownify_rs
MARKDOWNIFY_BENCH_PATH=/path/to/mi_statutes.jsonl .venv/bin/python scripts/bench_python.py --module markdownify --dist-name markdownify --label markdownify
# Law websites (CSV)
.venv/bin/python scripts/bench_python.py --format csv --path /path/to/deleted_pages.csv --module markdownify_rs --dist-name markdownify-rs --label markdownify_rs
.venv/bin/python scripts/bench_python.py --format csv --path /path/to/deleted_pages.csv --module markdownify --dist-name markdownify --label markdownify
Python binding comparison (both run through Python, 2026-01-28, Apple M3, macOS 14.6 / Darwin 24.6.0, Python 3.13.0)
Michigan Statutes (JSONL)
markdownify_rsconvert_all(241 docs): time 2.266594 s, throughput 42.508 MiB/smarkdownify_rsconvert_all_batch(241 docs): time 0.538012 s, throughput 179.084 MiB/smarkdownify_rsconvert_largest(8,034,686 bytes): time 187.941 ms, throughput 40.771 MiB/smarkdownifyconvert_all(241 docs): time 29.654787 s, throughput 3.249 MiB/smarkdownifyconvert_largest(8,034,686 bytes): time 4.496880 s, throughput 1.704 MiB/s
Speedup summary (wall-clock time, lower is better)
| Scenario | markdownify_rs time | markdownify_rs batch time | markdownify time | Speedup (rs vs py) | Speedup (batch vs py) | Batch vs rs |
|---|---|---|---|---|---|---|
| convert_all | 2.266594 s | 0.538012 s | 29.654787 s | 13.08x (+1208.34%) | 55.12x (+5411.92%) | 4.21x (+321.29%) |
| convert_largest | 187.941 ms | n/a | 4.496880 s | 23.93x (+2292.71%) | n/a | n/a |
Law websites (CSV)
markdownify_rsconvert_all(3,136 docs): time 2.596691 s, throughput 41.041 MiB/smarkdownify_rsconvert_all_batch(3,136 docs): time 0.672013 s, throughput 158.584 MiB/smarkdownify_rsconvert_largest(1,381,380 bytes): time 54.482 ms, throughput 24.180 MiB/smarkdownifyconvert_all(3,136 docs): time 17.680570 s, throughput 6.028 MiB/smarkdownifyconvert_largest(1,381,380 bytes): time 280.459 ms, throughput 4.697 MiB/s
Speedup summary (wall-clock time, lower is better)
| Scenario | markdownify_rs time | markdownify_rs batch time | markdownify time | Speedup (rs vs py) | Speedup (batch vs py) | Batch vs rs |
|---|---|---|---|---|---|---|
| convert_all | 2.596691 s | 0.672013 s | 17.680570 s | 6.81x (+580.89%) | 26.31x (+2530.99%) | 3.86x (+286.40%) |
| convert_largest | 54.482 ms | n/a | 280.459 ms | 5.15x (+414.77%) | n/a | n/a |
Markdown -> HTML parity/speed report:
.venv/bin/python scripts/report_markdown_to_html_parity_speed.py \
--corpus-dir /tmp/test_markdowns \
--report BENCHMARKS_MARKDOWN_TO_HTML.md
Metadata
Release files for markdownify-rs 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| markdownify_rs-0.2.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.8 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| markdownify_rs-0.2.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.8 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| markdownify_rs-0.2.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl | CPython 3.8 | abi3 | macOS 11.0+ ARM64, macOS 10.12+ universal2 (ARM64, x86-64), macOS 10.12+ x86-64 | Details |
Total release size: 7.6 MB
Release files / markdownify_rs-0.2.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | markdownify_rs-0.2.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 2.0 MB |
| Tags | CPython 3.8 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
29386adb71b1f28f703e034d286e986cc8b398986d8ec2305cd171aba2adb3d8
|
|
BLAKE2b-256 checksum How to use checksums |
06422923f91fc322a3a708f5021a6e13f35c573f515a8d6f19ec9439727b9d51
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.15
|
Release files / markdownify_rs-0.2.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | markdownify_rs-0.2.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 1.9 MB |
| Tags | CPython 3.8 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
53839d1ae172801aef56f39acde6695ba6edb7255d29aa880220a42aec41b077
|
|
BLAKE2b-256 checksum How to use checksums |
290d7def54740b4b0d9b311f6f42be2e7699f5c492b07decbd680364b9f36aa0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.15
|
Release files / markdownify_rs-0.2.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl
| Download URL | markdownify_rs-0.2.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl |
|---|---|
| Size | 3.6 MB |
| Tags | CPython 3.8 abi3 macOS 10.12+ universal2 (ARM64, x86-64) macOS 10.12+ x86-64 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
420809970a1ead47a4406300283cd3ddbeedcf0fad38962299b2632f48033d4d
|
|
BLAKE2b-256 checksum How to use checksums |
980226d0940d539b33521dc43741d3bcf39c8be123cd59eb21b8071f87f8858f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.15
|