mdka
mdka is a HTML to Markdown written in Rust. “ka” means “化 (か)” pointing to conversion.
It aims to strike a practical balance between conversion quality and runtime efficiency — readable output from real-world HTML, without sacrificing speed or memory.
At a Glance
| What you give it | What you get back |
|---|---|
| Any HTML string — a full page, a snippet, CMS output, SPA-rendered DOM | Clean, readable Markdown |
| A list of HTML files | Parallel Markdown output via rayon |
A conversion mode (minimal, semantic, …) | Pre-processed output tuned for your use case |
Key Properties
- Parser foundation: scraper, which is built on html5ever — the same battle-tested parser used by the Servo browser engine. It handles malformed, deeply-nested, and real-world HTML gracefully.
- Crash-resistant: a non-recursive DFS traversal means even 10,000 levels of nesting will not overflow the stack.
- Configurable: five conversion modes let you tune the pre-processing pipeline — from noise-free LLM input to lossless archiving.
- Multi-language: available as a Rust library, a Node.js package (napi-rs), and a Python package (PyO3).
When to Choose mdka
mdka is a good fit if you need:
- Stable, predictable output from diverse HTML sources (CMS, SPA, scraped pages)
- Mode-based pre-processing to strip navigation, preserve ARIA, or retain attributes
- Memory efficiency at scale (bulk file conversion, streaming pipelines)
- Multi-language access from a single underlying Rust implementation
If raw speed on simple, well-formed HTML is the only concern, a streaming rewriter will be faster.
Quick Navigation
- New to mdka? Start with Installation.
- Ready to integrate? Jump to Usage & Examples.
- Evaluating? Read Design Philosophy and Performance Characteristics.
Installation
As a Rust Library
Add mdka to your Cargo.toml:
[dependencies]
mdka = "2"
That is the only step. mdka has no system dependencies.
Minimum Supported Rust Version: 1.88 (2024 Edition)
As a CLI Binary
Download a prebuilt binary — no Rust toolchain needed — from the latest release. Which platforms have one, and the exact archive name for each, is listed in the README’s Quick Start; that table is the single source, so it is linked here rather than repeated.
Or install directly with cargo, which builds for whatever platform you are on:
cargo install mdka-cli
Or build from source using the mdka-cli crate in the workspace:
git clone https://github.com/nabbisen/mdka-rs
cd mdka-rs
cargo build --release -p mdka-cli
# Binary: ./target/release/mdka
As a Node.js Package
npm install mdka
# or
yarn add mdka
Requires Node.js 16 or later.
Prebuilt native bindings are published for three platforms, resolved
automatically through optionalDependencies:
| Platform | Package |
|---|---|
| Linux x64 (glibc) | @mdka/lib-linux-x64-gnu |
| macOS Apple Silicon | @mdka/lib-darwin-arm64 |
| Windows x64 (MSVC) | @mdka/lib-win32-x64-msvc |
On any other platform — musl, Linux arm64, macOS Intel, Windows ARM — there
is no fallback inside the package. The published tarball contains four files
(index.js, index.d.ts, package.json, README.md) and no Rust source, so
npm run build cannot work from an installed copy: there is nothing to build,
and the napi toolchain is a development dependency that is not installed for
consumers.
What does work on those platforms:
- Build the binding from the repository — clone
nabbisen/mdka-rs, then
cd node && npm install && npm run build, which needs a Rust toolchain. This produces a local binding; it does not makenpm install mdkawork elsewhere. - Use the CLI instead:
cargo install mdka-cli, which builds from source for whatever platform you are on. - Use the Rust crate directly, if the surrounding project allows it.
As a Python Package
pip install mdka
Requires CPython 3.10 or later.
Pre-built wheels are published for CPython on these platforms. Each wheel uses Python’s stable ABI, so one wheel serves every CPython from 3.10 upward, including versions released after it:
| Platform | Wheel tag |
|---|---|
| Linux x86_64, glibc 2.17 or later | manylinux_2_17_x86_64 |
| Linux aarch64, glibc 2.17 or later | manylinux_2_17_aarch64 |
| Linux x86_64, musl 1.2 or later (e.g. Alpine) | musllinux_1_2_x86_64 |
| Linux aarch64, musl 1.2 or later | musllinux_1_2_aarch64 |
| Windows x64 | win_amd64 |
| macOS on Apple silicon, 11.0 or later | macosx_11_0_arm64 |
Other interpreters — PyPy, free-threaded CPython — and other platforms have no
wheel. pip install mdka there falls back to the source distribution, which
needs a Rust toolchain to build. On free-threaded CPython that build works, but
mdka requires the GIL: Python re-enables it when mdka is imported, and emits a
RuntimeWarning saying so. To build from source on purpose:
pip install mdka --no-binary mdka with Rust installed.
These apply from mdka 2.3.0. On CPython 3.8 or 3.9, pip does not offer 2.3.0 or later and installs the latest 2.2.x release instead.
Usage & Examples
Choose the section for your environment:
- Rust — integrate directly into a Rust project
- Node.js — use from JavaScript or TypeScript
- Python — use from Python
- CLI — use from the command line
All four share the same underlying conversion engine, so results are consistent across languages.
Usage — Rust
Basic Conversion
use mdka::html_to_markdown;
fn main() {
let html = r#"
<h1>Getting Started</h1>
<p>mdka converts <strong>HTML</strong> to <em>Markdown</em>.</p>
<ul>
<li>Fast</li>
<li>Configurable</li>
<li>Crash-resistant</li>
</ul>
"#;
let md = html_to_markdown(html);
println!("{md}");
}
Output:
# Getting Started
mdka converts **HTML** to *Markdown*.
- Fast
- Configurable
- Crash-resistant
Conversion with Options
Use html_to_markdown_with to control the conversion pipeline via
ConversionOptions.
use mdka::{html_to_markdown_with};
use mdka::options::{ConversionMode, ConversionOptions};
// Strip navigation and extract body text — good for LLM input
let mut opts = ConversionOptions::for_mode(ConversionMode::Minimal);
opts.drop_interactive_shell = true;
let html = r#"
<header><nav><a href="/">Home</a></nav></header>
<main>
<article>
<h1>Article Title</h1>
<p>The main content of the page.</p>
</article>
</main>
<footer>Copyright 2025</footer>
"#;
let md = html_to_markdown_with(html, &opts);
assert!(md.contains("# Article Title"));
assert!(!md.contains("Home")); // nav removed
assert!(!md.contains("Copyright")); // footer removed
Converting a Single File
use mdka::html_file_to_markdown;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Output goes to the same directory as the input: page.html → page.md
let result = html_file_to_markdown("page.html", None::<&str>)?;
println!("{} → {}", result.src.display(), result.dest.display());
// Output goes to a specific directory
let result = html_file_to_markdown("page.html", Some("out/"))?;
Ok(())
}
Bulk Parallel Conversion
use mdka::html_files_to_markdown;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let files = vec!["a.html", "b.html", "c.html"];
let out_dir = Path::new("out/");
std::fs::create_dir_all(out_dir)?;
for (src, result) in html_files_to_markdown(&files, out_dir) {
match result {
Ok(dest) => println!("{} → {}", src, dest.display()),
Err(e) => eprintln!("Error: {src}: {e}"),
}
}
Ok(())
}
Conversion runs in parallel using rayon. The number of threads defaults to the number of logical CPU cores.
Bulk Conversion with Options
use mdka::{html_files_to_markdown_with};
use mdka::options::{ConversionMode, ConversionOptions};
use std::path::Path;
let opts = ConversionOptions::for_mode(ConversionMode::Semantic);
let files = vec!["a.html", "b.html"];
let results = html_files_to_markdown_with(&files, Path::new("out/"), &opts);
Conversion Modes at a Glance
| Mode | Best for |
|---|---|
Balanced | General use; default |
Strict | Debugging, diff comparison |
Minimal | LLM pre-processing, compression |
Semantic | SPA content, accessibility-aware output |
Preserve | Archiving, audit trails |
See Conversion Modes for full details.
Error Handling
use mdka::{html_file_to_markdown, MdkaError};
match html_file_to_markdown("missing.html", None::<&str>) {
Ok(result) => println!("→ {}", result.dest.display()),
Err(MdkaError::Io(e)) => eprintln!("IO error: {e}"),
}
MdkaError currently has one variant: Io, wrapping std::io::Error.
html_to_markdown and html_to_markdown_with are infallible — they always
return a String and never panic on any input, no matter how malformed.
Usage — Node.js
Installation
npm install mdka
Basic Conversion
const { htmlToMarkdown } = require('mdka')
const html = `
<h1>Hello</h1>
<p>mdka converts <strong>HTML</strong> to <em>Markdown</em>.</p>
`
const md = htmlToMarkdown(html)
console.log(md)
// # Hello
//
// mdka converts **HTML** to *Markdown*.
Async Conversion
htmlToMarkdownAsync offloads work to a Rust thread pool, keeping the
Node.js event loop free:
const { htmlToMarkdownAsync } = require('mdka')
const html = '<h1>Hello</h1>'
const pages = [
{ html: '<h1>A</h1>' },
{ html: '<p>B</p>' },
]
async function main() {
const md = await htmlToMarkdownAsync(html)
// Concurrent conversion of many pages
const results = await Promise.all(pages.map(p => htmlToMarkdownAsync(p.html)))
console.log(md, results)
}
main()
Conversion with Options
const { htmlToMarkdownWith, htmlToMarkdownWithAsync } = require('mdka')
const html = '<nav>menu</nav><h1>Title</h1><p>Body</p>'
// Strip nav/header/footer — useful for content extraction
const md = htmlToMarkdownWith(html, {
mode: 'minimal',
dropInteractiveShell: true,
})
// Async version
async function main() {
const mdAsync = await htmlToMarkdownWithAsync(html, { mode: 'semantic' })
console.log(mdAsync)
}
main()
Available mode strings: "balanced" (default), "strict", "minimal",
"semantic", "preserve".
Single File Conversion
const { htmlFileToMarkdown, htmlFileToMarkdownWith } = require('mdka')
async function main() {
// Output to same directory: page.html → page.md
const sameDir = await htmlFileToMarkdown('page.html')
console.log(`${sameDir.src} → ${sameDir.dest}`)
// Output to specific directory
const outDir = await htmlFileToMarkdown('page.html', 'out/')
// With options
const withOpts = await htmlFileToMarkdownWith('page.html', 'out/', {
mode: 'minimal',
dropInteractiveShell: true,
})
}
main()
Bulk Parallel Conversion
const { htmlFilesToMarkdown, htmlFilesToMarkdownWith } = require('mdka')
const files = ['a.html', 'b.html', 'c.html']
async function main() {
const results = await htmlFilesToMarkdown(files, 'out/')
for (const r of results) {
if (r.error) console.error(`${r.src}: ${r.error}`)
else console.log(`${r.src} → ${r.dest}`)
}
// With options
const withOpts = await htmlFilesToMarkdownWith(files, 'out/', {
mode: 'semantic',
})
}
main()
Two inputs whose output names collide — a/index.html and b/index.html both
becoming out/index.md — are not both converted. The first in the array wins
and each later one comes back with error set, rather than silently
overwriting.
Three of the deprecated attribute options are accepted here and have no
effect: preserveClasses, preserveDataAttrs and preserveAriaAttrs.
Markdown has no attribute syntax to carry them into.
Passing any of the three to a synchronous function emits a
DeprecationWarning. By default the call still succeeds — but under
node --throw-deprecation it throws. Remove the option; it changes nothing.
The Async functions cannot emit the warning at all, so silence from them is
not evidence that no deprecated option is in use. While migrating, suppress
mdka’s notices narrowly:
const { htmlToMarkdownWith } = require('mdka')
// Drop only mdka's own deprecation notices; everything else passes through.
const emitWarning = process.emitWarning
process.emitWarning = function (warning, ...rest) {
const message = typeof warning === 'string' ? warning : warning?.message
if (message?.startsWith('mdka: `')) return
return emitWarning.call(process, warning, ...rest)
}
const md = htmlToMarkdownWith('<p>x</p>', { preserveClasses: true })
This still works under --throw-deprecation, and other deprecation warnings
throw as before.
The other two — preserveUnknownAttrs and dropPresentationAttrs — exist on
the Rust ConversionOptions but are not fields of JsConversionOptions,
which has seven. In TypeScript, passing either is a compile error:
TS2353: Object literal may only specify known properties, and
'preserveUnknownAttrs' does not exist in type 'JsConversionOptions'.
Use mode.
TypeScript
Type definitions are bundled. No @types/ package is needed:
import {
htmlToMarkdown,
htmlToMarkdownWith,
htmlToMarkdownAsync,
htmlFileToMarkdown,
htmlFilesToMarkdown,
type JsConversionOptions,
type ConvertResult,
} from 'mdka'
const html: string = '<h1>Title</h1>'
const opts: JsConversionOptions = {
mode: 'minimal',
dropInteractiveShell: true,
}
const md: string = htmlToMarkdownWith(html, opts)
The options type is exported as JsConversionOptions, not
ConversionOptions — the name comes from the napi-rs binding rather than from
the Rust ConversionOptions it mirrors.
Usage — Python
Installation
pip install mdka
Basic Conversion
import mdka
html = """
<h1>Hello</h1>
<p>mdka converts <strong>HTML</strong> to <em>Markdown</em>.</p>
"""
md = mdka.html_to_markdown(html)
print(md)
# # Hello
#
# mdka converts **HTML** to *Markdown*.
Conversion with Options
import mdka
html = "<nav>menu</nav><h1>Title</h1><p>Body</p>"
# Strip nav/header/footer — useful for LLM pre-processing
md = mdka.html_to_markdown_with(
html,
mode=mdka.ConversionMode.Minimal,
drop_interactive_shell=True,
)
# Favour semantic structure — for SPAs and accessibility-aware output
md = mdka.html_to_markdown_with(
html,
mode=mdka.ConversionMode.Semantic,
)
Three of the deprecated attribute options are accepted here and have no
effect: preserve_classes, preserve_data_attrs and preserve_aria_attrs.
Markdown has no attribute syntax to carry them into.
Passing any of the three emits a DeprecationWarning. By default that is only a
warning and the call succeeds — but under warnings-as-errors it fails:
python -W error, or pytest with filterwarnings = error, turns the call into
a raised DeprecationWarning. Remove the argument; it changes nothing. While
migrating, suppress it narrowly — for mdka’s notices only, for one block:
import warnings
import mdka
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=r"mdka: `preserve_",
)
md = mdka.html_to_markdown_with("<p>x</p>", preserve_classes=True)
This still works under python -W error, and leaves every other library’s
deprecation warnings raising as before.
The other two — preserve_unknown_attrs and drop_presentation_attrs — exist
on the Rust ConversionOptions but are not exposed by this binding at all.
Passing either raises:
TypeError: html_to_markdown_with() got an unexpected keyword argument
'preserve_unknown_attrs'
Use mode to influence the output.
Available modes: ConversionMode.Balanced (default), Strict, Minimal,
Semantic, Preserve.
Parallel Batch Conversion (GIL released)
html_to_markdown_many releases the GIL and uses rayon for parallel conversion:
import mdka
pages = ["<h1>A</h1>", "<p>B</p>", "<ul><li>C</li></ul>"]
results = mdka.html_to_markdown_many(pages)
# ['# A\n', 'B\n', '- C\n']
This is faster than calling html_to_markdown in a Python loop for large batches.
Single File Conversion
import mdka
# Output to same directory: page.html → page.md
result = mdka.html_file_to_markdown("page.html")
print(f"{result.src} → {result.dest}")
# Output to a specific directory
result = mdka.html_file_to_markdown("page.html", "out/")
# With options
result = mdka.html_file_to_markdown(
"page.html",
"out/",
mode=mdka.ConversionMode.Minimal,
drop_interactive_shell=True,
)
Bulk File Conversion
import mdka
files = ["a.html", "b.html", "c.html"]
results = mdka.html_files_to_markdown(files, "out/")
for r in results:
if r.ok:
print(f"{r.src} → {r.dest}")
else:
print(f"Error: {r.src}: {r.error}")
Error Handling
import mdka
try:
result = mdka.html_file_to_markdown("missing.html")
except mdka.MdkaError as e:
print(f"Conversion failed: {e}")
MdkaError is raised for IO errors (file not found, permission denied, etc.).
html_to_markdown and html_to_markdown_with are always safe to call — they
never raise exceptions regardless of input quality.
Type Annotations
mdka does not ship type information. There is no py.typed marker and no
.pyi stubs, so a type checker treats every symbol below as Any. mypy will
say so directly:
error: Skipping analyzing "mdka": module is installed, but missing library
stubs or py.typed marker [import-untyped]
That message is accurate, and it is better than the alternative. Every public
symbol is implemented in Rust and exposed through a compiled extension module,
which a type checker cannot read signatures from. Shipping a bare py.typed
would silence the warning without providing anything to check — the symbols
would still resolve as Any, and a genuinely wrong annotation would then pass
silently. Typed stubs are the real fix and are not written yet.
Until then, the signatures are:
from mdka import (
html_to_markdown, # (html: str) -> str
html_to_markdown_with, # (html: str, mode=..., **flags) -> str
html_to_markdown_many, # (html_list: list[str]) -> list[str]
html_file_to_markdown, # (path, out_dir=None, ...) -> ConvertResult
html_files_to_markdown, # (paths, out_dir, ...) -> list[BulkConvertResult]
ConversionMode, # enum
ConvertResult, # dataclass: src, dest (str)
BulkConvertResult, # dataclass: src, dest?, error?, ok
MdkaError, # exception
)
Usage — CLI
The mdka command-line tool is provided by the mdka-cli crate.
Quick Reference
mdka [OPTIONS] [FILE...]
Run mdka --help to see the full option list with descriptions.
Common Patterns
Convert from stdin:
echo '<h1>Hello</h1>' | mdka
curl https://example.com | mdka
Convert a single file (output goes to the same directory):
mdka page.html # → page.md
Convert to a specific directory:
mdka -o out/ page.html # → out/page.md
Bulk conversion (-o is required for multiple files):
mdka -o out/ docs/*.html
Choose a conversion mode:
mdka --mode minimal --drop-shell page.html # extract body text
mdka --mode preserve -o archive/ *.html # maximum fidelity
All Options
This table mirrors mdka --help. If the two ever disagree, --help is the
truth — it is generated from the binary you are running.
| Flag | Description |
|---|---|
-o, --output <DIR> | Output directory (defaults to the input’s directory) |
-m, --mode <MODE> | Conversion mode: balanced (default) · strict · minimal · semantic · preserve |
--preserve-ids | Keep id attributes |
--preserve-classes | Deprecated, no effect. Markdown has no attribute syntax |
--preserve-data | Deprecated, no effect. Same reason |
--preserve-aria | Deprecated, no effect. Same reason |
--drop-shell | Drop nav, header, footer, aside |
--unwrap-wrappers | Unwrap div, span, section, article, main that carry no meaning |
-h, --help | Show this help |
-V, --version | Show the version |
-- | End of options; everything after is a path |
An unrecognised --prefixed argument is rejected rather than treated as a
filename. If you genuinely have a file whose name begins with -, put --
before it: mdka -- -weird.html.
The three deprecated flags are still accepted, so existing command lines keep
working, but they change nothing about the output. They are documented here
only so that you can recognise them; do not reach for them expecting an
effect. See ConversionOptions.
For full mode descriptions see Conversion Modes.
API Reference
mdka exposes a small, focused public API. The table below shows the complete surface — every function and type you need, nothing you don’t.
Functions
| Function | Language | Description |
|---|---|---|
html_to_markdown | Rust | Convert HTML string → Markdown (default mode) |
html_to_markdown_with | Rust | Convert with explicit ConversionOptions |
html_file_to_markdown | Rust | Convert one file; output alongside input or to out_dir |
html_file_to_markdown_with | Rust | Single file with options |
html_files_to_markdown | Rust | Parallel bulk conversion (rayon) |
html_files_to_markdown_with | Rust | Bulk with options |
Types
| Type | Description |
|---|---|
ConversionMode | Enum: Balanced · Strict · Minimal · Semantic · Preserve |
ConversionOptions | Controls pre-processing per-call; built via for_mode() |
ConvertResult | Returned by single-file functions: src + dest paths |
MdkaError | The only error type: wraps std::io::Error |
Guarantees
html_to_markdownandhtml_to_markdown_withnever panic. They accept any&str, including empty strings, binary garbage, or deeply nested HTML.- File functions propagate IO errors via
Result<_, MdkaError>. - Output is always valid UTF-8.
- Output always ends with a single newline when the input produces any content.
Core Functions
html_to_markdown
pub fn html_to_markdown(html: &str) -> String
Converts an HTML string to Markdown using the default Balanced mode.
Input: Any valid or malformed HTML string. Empty strings are accepted.
Output: A Markdown string. Always ends with \n if the input produced any content.
Errors: None — this function is infallible.
let md = mdka::html_to_markdown("<h1>Hello</h1>");
assert_eq!(md, "# Hello\n");
html_to_markdown_with
pub fn html_to_markdown_with(html: &str, opts: &ConversionOptions) -> String
Same as html_to_markdown, but accepts a ConversionOptions
value that controls pre-processing and conversion behaviour.
Input: Any HTML string + a ConversionOptions value.
Output: Markdown string.
Errors: None.
use mdka::options::{ConversionMode, ConversionOptions};
let mut opts = ConversionOptions::for_mode(ConversionMode::Minimal);
opts.drop_interactive_shell = true;
let md = mdka::html_to_markdown_with(html, &opts);
html_file_to_markdown
pub fn html_file_to_markdown(
path: impl AsRef<Path>,
out_dir: Option<impl AsRef<Path>>,
) -> Result<ConvertResult, MdkaError>
Reads one HTML file, converts it, and writes a .md file.
path: Path to the input .html file.out_dir:
None→ the.mdfile is written alongside the input (same directory, stem unchanged).Some(dir)→ the.mdfile is written intodir. The directory is created automatically if it does not exist.
Returns: ConvertResult with the resolved src and dest paths.
Errors: MdkaError::Io if the file cannot be read or the output cannot be written.
fn main() -> Result<(), Box<dyn std::error::Error>> {
// page.html → page.md in the same folder
let r = mdka::html_file_to_markdown("page.html", None::<&str>)?;
// page.html → out/page.md
let r = mdka::html_file_to_markdown("page.html", Some("out/"))?;
println!("{} → {}", r.src.display(), r.dest.display());
Ok(())
}
html_file_to_markdown_with
pub fn html_file_to_markdown_with(
path: impl AsRef<Path>,
out_dir: Option<impl AsRef<Path>>,
opts: &ConversionOptions,
) -> Result<ConvertResult, MdkaError>
Same as html_file_to_markdown, but applies the given ConversionOptions.
html_files_to_markdown
pub fn html_files_to_markdown<'a, P>(
paths: &'a [P],
out_dir: &Path,
) -> Vec<(&'a P, Result<PathBuf, MdkaError>)>
where
P: AsRef<Path> + Sync,
Converts multiple HTML files in parallel using rayon.
paths: Slice of paths to input HTML files.out_dir: Directory for all output .md files. Created automatically if it does not exist, as with the single-file variants.
Returns: A Vec of (input_path, Result<output_path, error>) pairs in the same order as paths. Each element represents the outcome for one file independently.
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let files = vec!["a.html", "b.html", "c.html"];
std::fs::create_dir_all("out/")?;
for (src, result) in mdka::html_files_to_markdown(&files, Path::new("out/")) {
match result {
Ok(dest) => println!("{} → {}", src, dest.display()),
Err(e) => eprintln!("{src}: {e}"),
}
}
Ok(())
}
html_files_to_markdown_with
pub fn html_files_to_markdown_with<'a, P>(
paths: &'a [P],
out_dir: &Path,
opts: &ConversionOptions,
) -> Vec<(&'a P, Result<PathBuf, MdkaError>)>
where
P: AsRef<Path> + Sync,
Same as html_files_to_markdown, but applies the given ConversionOptions to every file.
ConvertResult
pub struct ConvertResult {
pub src: PathBuf,
pub dest: PathBuf,
}
Returned by the single-file functions. Both fields are absolute or relative paths
depending on how path was passed in.
Note: The bulk functions (
html_files_to_markdown*) return(&P, Result<PathBuf, MdkaError>)tuples rather thanConvertResult, because individual files within a batch may fail independently.
Conversion Modes
A conversion mode selects a preset of ConversionOptions
fields. mdka reads these fields directly during its single-pass DOM
traversal — there is no separate pre-processing stage.
Overview
| Mode | Default? |
|---|---|
Balanced | ✅ Yes |
Strict | |
Minimal | |
Semantic | |
Preserve |
⚠ Balanced, Strict, and Preserve currently produce identical output
This is the single most important fact on this page.
Balanced, Strict, and Preserve differ from each other only in the
defaults of five fields — preserve_classes, preserve_data_attrs,
preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrs —
and those five fields have no effect on output (see
Field Reference). The fields that do
affect output — preserve_ids, drop_interactive_shell,
unwrap_unknown_wrappers — have the same value across all three modes.
This is a statement about today’s behaviour, not a deprecation. The
three modes remain distinct API, are not merged, and may diverge again if
attribute preservation is ever implemented as a real feature. Proven
directly in
tests/characterisation_structural.rs
(balanced_strict_preserve_are_identical_on_the_wrapper_fixture,
balanced_strict_preserve_are_identical_on_an_attribute_rich_element),
which run all three through fixtures specifically chosen to discriminate a
difference if one existed, rather than inferring identity from fixtures
that happen not to distinguish them.
Minimal and Semantic are genuinely distinct from the other three and
from each other — Minimal additionally drops shell elements
(drop_interactive_shell), and Semantic additionally unwraps generic
wrappers (unwrap_unknown_wrappers) without dropping shell elements.
Balanced (default)
What it does today: keeps id attributes (emits anchors), keeps shell
elements (nav/header/footer/aside), does not unwrap wrapper
elements.
let md = mdka::html_to_markdown(html); // Balanced is the default
Use when: you want the default behaviour without extra configuration.
Strict
Currently identical to Balanced and Preserve — see the notice
above. Distinct API, in case attribute preservation becomes a real feature
later.
use mdka::options::{ConversionMode, ConversionOptions};
let opts = ConversionOptions::for_mode(ConversionMode::Strict);
let md = mdka::html_to_markdown_with(html, &opts);
Minimal
What it does today: drops shell elements (nav/header/footer/aside
and their children), unwraps generic wrapper elements
(div/span/section/article/main), does not emit id anchors.
The most aggressive mode for extracting body content — useful for piping into an LLM prompt or a search index, where surrounding navigation chrome and wrapper markup are noise.
let opts = ConversionOptions::for_mode(ConversionMode::Minimal);
let md = mdka::html_to_markdown_with(html, &opts);
Semantic
What it does today: keeps shell elements, unwraps generic wrapper
elements, emits id anchors. The one mode that unwraps wrappers without
dropping shell elements — useful when you want compact structure but still
need navigation landmarks preserved.
let opts = ConversionOptions::for_mode(ConversionMode::Semantic);
let md = mdka::html_to_markdown_with(html, &opts);
Preserve
Currently identical to Balanced and Strict — see the notice above.
Distinct API, in case attribute preservation becomes a real feature later.
let opts = ConversionOptions::for_mode(ConversionMode::Preserve);
let md = mdka::html_to_markdown_with(html, &opts);
Choosing a Mode
Want wrappers unwrapped, but keep nav/header/footer? → Semantic
Want the most aggressive extraction (LLM input, etc.)? → Minimal
Everything else → Balanced (default)
Strict and Preserve are not listed above because they currently behave
identically to Balanced — pick Balanced unless you specifically want
the distinct API surface for forward compatibility.
ConversionOptions
pub struct ConversionOptions {
pub mode: ConversionMode,
// Attribute retention
pub preserve_ids: bool,
pub preserve_classes: bool, // deprecated, no effect
pub preserve_data_attrs: bool, // deprecated, no effect
pub preserve_aria_attrs: bool, // deprecated, no effect
pub preserve_unknown_attrs: bool, // deprecated, no effect
// Structural behaviour
pub drop_presentation_attrs: bool, // deprecated, no effect
pub drop_interactive_shell: bool,
pub unwrap_unknown_wrappers: bool,
}
ConversionOptions controls the details of how mdka’s single-pass DOM
traversal renders Markdown. There is no separate pre-processing stage —
the traversal in src/traversal.rs reads these fields directly as it
walks the parsed document once. You rarely need to set individual fields —
start with a mode and override only what differs from the default for
that mode.
Five of the eight fields below have no effect on output and are
deprecated as of 2.2.0. Markdown has no attribute syntax, so
“preserve this attribute” was never expressible in the output format —
see RFC 005
for the full history. They are marked below; nothing is removed, and no
output changes if you are currently setting them.
Creating Options
From a mode (recommended)
use mdka::options::{ConversionMode, ConversionOptions};
let opts = ConversionOptions::for_mode(ConversionMode::Minimal);
for_mode returns sensible defaults for the chosen mode. See the table below.
Modify fields after creation
let mut opts = ConversionOptions::for_mode(ConversionMode::Balanced);
opts.drop_interactive_shell = true; // also strip nav/header/footer/aside
opts.preserve_ids = false; // don't emit <a id="…"> anchors
Default
let opts = ConversionOptions::default(); // equivalent to for_mode(Balanced)
Field Defaults by Mode
| Field | Balanced | Strict | Minimal | Semantic | Preserve | Effect |
|---|---|---|---|---|---|---|
preserve_ids | ✅ | ✅ | ❌ | ✅ | ✅ | Emits anchors |
preserve_classes | ❌ | ✅ | ❌ | ❌ | ✅ | None — deprecated |
preserve_data_attrs | ❌ | ✅ | ❌ | ❌ | ✅ | None — deprecated |
preserve_aria_attrs | ✅ | ✅ | ❌ | ✅ | ✅ | None — deprecated |
preserve_unknown_attrs | ❌ | ✅ | ❌ | ❌ | ✅ | None — deprecated |
drop_presentation_attrs | ✅ | ❌ | ✅ | ✅ | ❌ | None — deprecated |
drop_interactive_shell | ❌ | ❌ | ✅ | ❌ | ❌ | Drops shell elements |
unwrap_unknown_wrappers | ❌ | ❌ | ✅ | ✅ | ❌ | Unwraps wrapper elements |
Because the five deprecated fields have no effect, Balanced, Strict,
and Preserve currently produce byte-identical output — they differ
from each other only in these fields’ defaults. See
Conversion Modes for what this means when choosing a mode.
Field Reference
mode
The ConversionMode this options object was built from.
Changing mode after construction does not re-apply mode defaults
to the other fields — use for_mode() again instead.
preserve_ids
Whether to emit an anchor for elements carrying a non-empty id
attribute. When enabled, <h2 id="install">Install</h2> produces:
## <a id="install"></a>Install
The anchor is the element’s leading content, placed after any heading marker, list marker, or blockquote prefix:
| Input | Output |
|---|---|
<h2 id="x">Text</h2> | ## <a id="x"></a>Text |
<li id="x">Text</li> | - <a id="x"></a>Text |
<p id="x">Text</p> inside a <blockquote> | > <a id="x"></a>Text |
Exception: <a> and <pre>. These two elements open their own
inline-link capture or code-fence region as part of entering them, so
their anchor is emitted before the element instead, to avoid disturbing
the link text or code content:
| Input | Output |
|---|---|
<a id="x" href="/">text</a> | <a id="x"></a>[text](/) |
<pre id="x"><code>y</code></pre> | <a id="x"></a> on its own line, then the fenced block |
An id on a descendant of a link or a code block is deliberately
not emitted — an anchor injected into captured link text or into
literal code content would corrupt it. <a href="/"><span id="s">Home</span></a>
produces [Home](/) with no anchor for s.
The id value is escaped for HTML attribute context (& → &,
" → ") before being written — this is the one place mdka
constructs new HTML from an input-derived value, rather than passing
existing markup through.
An empty id="" emits nothing. preserve_ids = false emits nothing
regardless of id.
preserve_classes, preserve_data_attrs, preserve_aria_attrs, preserve_unknown_attrs, drop_presentation_attrs
No effect on output. Deprecated since 2.2.0. Markdown has no syntax
for HTML attributes, so “preserve” or “drop” an attribute was never
expressible in the output — these fields described behaviour the format
could not represent, and never changed a single byte of Markdown in any
released version. See
RFC 005
for the analysis. They remain present on the struct and accept any value, and
they change nothing about the output.
Setting one is not silent, though: each field is #[deprecated], so it is a
compile-time warning. Under -D warnings it is a build failure —
RUSTFLAGS="-D warnings", or #![deny(warnings)] in your crate. Remove the
assignment; it changes nothing. While migrating, allow it narrowly, around the
assignment only rather than for the whole crate:
use mdka::options::{ConversionMode, ConversionOptions};
let mut opts = ConversionOptions::for_mode(ConversionMode::Balanced);
#[allow(deprecated)]
{
opts.preserve_classes = true;
}
This builds under -D warnings.
Attribute preservation is a legitimate feature some Markdown flavours (Pandoc, kramdown) support. If mdka adds it, it will be a new, deliberately designed feature — not a repair of these fields.
drop_interactive_shell
Whether to remove <nav>, <header>, <footer>, and <aside> elements
and all their children.
Useful for content extraction from full web pages.
Enabled by default in Minimal; disabled by default in every other mode.
unwrap_unknown_wrappers
Whether to replace <div>, <span>, <section>, <article>, and
<main> with their children, discarding the wrapper tag itself, when
unwrap_unknown_wrappers is enabled. Enabled in Minimal and Semantic.
<figure> and <figcaption> are never unwrapped, in any mode — see
the Block Elements table for why they’re excluded even
though they visually resemble the other wrapper elements.
Error Handling
MdkaError
#[derive(Error, Debug)]
pub enum MdkaError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
MdkaError is the only error type in mdka. It has one variant, Io,
which wraps a std::io::Error.
IO errors arise from the file-based functions when:
- the input file does not exist or is not readable
- the output directory cannot be created
- the output file cannot be written
Infallible Functions
html_to_markdown and html_to_markdown_with never fail. They accept
any string and return a String. Malformed HTML, empty input, binary-looking
content, deeply nested structures — none of these cause a panic or an error.
Pattern Matching
use mdka::{html_file_to_markdown, MdkaError};
match html_file_to_markdown("page.html", None::<&str>) {
Ok(result) => println!("→ {}", result.dest.display()),
Err(MdkaError::Io(e)) => eprintln!("IO error: {e}"),
}
Because there is only one variant today, you can also use ? directly:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let result = mdka::html_file_to_markdown("page.html", None::<&str>)?;
Ok(())
}
Bulk Conversion Errors
In html_files_to_markdown, each file fails independently.
A failed file does not abort the rest of the batch:
for (src, result) in mdka::html_files_to_markdown(&files, Path::new("out/")) {
if let Err(e) = result {
eprintln!("skipped {}: {e}", src);
}
}
Supported HTML Elements
The table below shows every HTML element that mdka recognises and what Markdown it produces. Elements not listed are either silently removed (script, style, etc.) or their children are kept as plain text.
Block Elements
| HTML | Markdown output | Notes |
|---|---|---|
<h1> – <h6> | # – ###### | ATX-style headings |
<p> | Paragraph (blank lines around) | |
<blockquote> | > prefix | On every line inside the quote, including blank lines (>) and code block lines. Nesting produces > > , > > > , … |
<pre><code> | Fenced code block ``` | Preserves whitespace and newlines |
<ul> | - list | Tight or loose, see Lists |
<ol> | 1. list | Respects start attribute. Tight or loose, see Lists |
<li> | List item | Everything inside it stays in the item: later lines are indented to the item’s content column (- → 2 spaces, 1. → 3, 10. → 4), including nested lists and code block lines |
<hr> | --- | |
<div>, <article>, <section>, <main> | Block separator | Act as paragraph breaks; unwrapped (tag removed, children kept) when unwrap_unknown_wrappers is on — Minimal and Semantic by default |
<figure>, <figcaption> | Block separator | Never unwrapped, in any mode. These carry structural meaning unwrap_unknown_wrappers is not meant to discard — they’re excluded from the wrapper-candidate set entirely, not merely blocked by a secondary check |
Inline Elements
| HTML | Markdown output | Notes |
|---|---|---|
<strong>, <b> | **text** | |
<em>, <i> | *text* | |
<code> (inline) | `text` | Only when not inside <pre> |
<a href="…"> | [text](url) | title attribute → [text](url "title") |
<img src="…" alt="…"> |  | title attribute →  |
<br> | \n (trailing two spaces + newline) |
<span> is not in either table, and that is deliberate: it produces no
output of its own and no break. <span>A</span><span>B</span> converts to
AB, with the children passed straight through.
Lists: tight and loose
Markdown has two kinds of list: tight, with no blank line between items,
and loose, where items are separated by blank lines and each item’s text is
a paragraph. HTML has no such distinction — whether an item’s text is wrapped
in <p> is usually a matter of how the page was produced — so mdka decides by
the item’s content:
A list is loose if and only if some item contains two or more blocks, where a run of inline content (text, links, bold, images, line breaks) counts as one block, and nested lists are not counted. Otherwise it is tight.
A block is a paragraph, heading, blockquote, code block or rule, and anything
else the chosen mode renders as a block: <div> counts in Balanced, Strict and
Preserve, but not in Minimal and Semantic, which unwrap it; an element the
mode drops counts as nothing.
A CMS list with one <p> per item stays tight:
<ul><li><p>a</p></li><li><p>b</p></li></ul>
- a
- b
A nested list is not counted, so text followed by a sublist is still one block:
<ol><li>one<ol><li>inner</li></ol></li></ol>
1. one
1. inner
Two paragraphs in one item make the whole list loose:
<ul><li><p>a</p><p>b</p></li><li>c</li></ul>
- a
b
- c
Each list is decided on its own: a loose list nested inside a tight one leaves the outer list tight.
Not Yet Supported
These elements are not converted to their Markdown equivalent. Their text content still appears — children are kept as plain text — so the output is not empty, but the structure or emphasis they carry is lost.
| HTML | Current behaviour | Status |
|---|---|---|
<table>, <thead>, <tbody>, <tr>, <th>, <td> | Cell text is emitted as plain text; no GFM table is produced and the row/column structure is lost | Planned |
<dl>, <dt>, <dd> | Term and description text run together as plain text | Planned |
<del>, <s> | Text kept, strike-through (~~text~~) not emitted | Planned |
<sup>, <sub> | Text kept inline, with no indication it was raised or lowered | Planned |
<video>, <audio> | No output for the media element itself | Not yet scheduled |
Tables are the largest gap, and the cell text is not merely unstructured — it is run together without separators:
<table><thead><tr><th>H1</th><th>H2</th></tr></thead><tbody><tr><td>a</td><td>b</td></tr></tbody></table>
converts to H1H2ab. mdka adds no separator between cells; the only spacing
that survives is whitespace already present in the source, so the same table
written across two lines comes out as H1H2 ab. If your input is table-heavy,
the converted Markdown will read as runs of joined text where the table was.
See
ROADMAP.md for
scheduling.
Code Blocks and Language Hints
When a <code> element has a class containing language-<name>, the
language name is included in the fenced block:
<pre><code class="language-rust">fn main() {}</code></pre>
Produces:
```rust
fn main() {}
```
The language-* class is preserved in all conversion modes, including
Balanced which otherwise strips class attributes.
Always-Removed Elements
These elements and all their descendants are removed unconditionally, regardless of conversion mode:
<script> · <style> · <meta> · <link> · <template> ·
<iframe> · <object> · <embed> · <noscript> · <head> · <svg>
HTML comments are removed in all conversion modes, including
Preserve. No mode retains comment content.
Shell Elements
<nav>, <header>, <footer>, <aside> are kept by default but can
be removed by setting drop_interactive_shell = true
or using ConversionMode::Minimal.
Text Processing Rules
mdka applies a small set of deterministic rules to produce consistent, readable Markdown from any HTML text content.
Whitespace Normalisation
HTML text nodes are normalised according to the HTML whitespace collapsing rules:
- Leading and trailing whitespace is trimmed from block-level context.
- Consecutive whitespace characters (spaces, tabs, newlines) within a text node are collapsed to a single space.
- A single space is preserved between adjacent inline elements.
<br>produces a hard line break (\n).<pre>blocks are exempt — whitespace inside<pre>is reproduced exactly.
This is done in a single pass without regular expressions, which keeps allocation overhead low.
Markdown Escaping
Text is escaped by the context it is written in, so that the Markdown parses back — under CommonMark and GitHub Flavored Markdown — to exactly the text the HTML contained, with as few backslashes as possible. No character is escaped everywhere: a character gets a backslash only where, without one, it would be read as Markdown.
Text
In paragraphs, headings, list items, quotes and link text:
| Text | Escaped | Not escaped |
|---|---|---|
* and ~ | where they could open or close emphasis or strikethrough: *bold* → \*bold\* | with a space on both sides: 2 * 3 |
_ | the same, except inside a word | snake_case_here |
` | always | |
[ | always | |
] | inside link text and image alt text | elsewhere |
! | before [, where it would start an image | Wow! Really! |
< | before a letter, /, ! or ? — a tag, a comment or an autolink: <div> → \<div> | a < b, <3 |
& | before a letter, a digit or # — a possible entity: © → \© | a & b |
\ | before punctuation or at the end of a line | path\to\file |
A few characters need a backslash only at the start of a block’s text —
after any - , 1. or > that a list item or quote puts in front of it —
or at the start of a line after a <br>:
| Text begins with | Would become | Written as |
|---|---|---|
a number, then . or ) | an ordered list | 1986\. A great year — the delimiter is escaped, not the digit |
- or +, then a space | a bullet list | \- not a list |
#, then a space | a heading | \# not a heading |
> | a quote | \> not a quote |
~~~ | a code block | \~\~\~ |
--- | a thematic break | \--- |
After a <br>, a line of = or - would turn the paragraph into a heading,
and a line such as | --- | --- | under a line containing | would turn it
into a GFM table: its first character is escaped. In a heading, a trailing #
is escaped, since it would be read as the heading’s closing sequence.
Code
Code spans and code blocks hold their text verbatim, with no escapes. The delimiter is sized to the content instead:
<p><code>snake_case</code> and <code>a`b</code></p>
`snake_case` and ``a`b``
A code block’s fence is one backtick longer than the longest run of backticks that starts a line inside it, and never shorter than three. A language hint containing a backtick is dropped, since a fence cannot hold one.
Links and images
A destination with a space, a control character or unbalanced parentheses is
written in angle brackets; a line break in it is written as . A title is
written in "…", or in '…' or (…) when that avoids escaping its quotes:
<a href="/a b.html" title='say "hi"'>x</a>
[x](</a b.html> 'say "hi"')
Adjacent emphasis
Two emphasized runs of the same kind that touch would merge into one run of
*, which CommonMark reads differently. One of them is written with _:
<p><em>a</em><em>b</em></p>
_a_*b*
Between two letters or digits (x<em>a</em><em>b</em>y) neither * nor _
can express this, and the output is left as it is.
HTML Entity Decoding
HTML entities in text nodes are decoded by the HTML parser (scraper / html5ever) before mdka processes them. The result is already Unicode text:
| HTML entity | After parsing | In Markdown |
|---|---|---|
& | & | & (\& before a letter, a digit or #) |
< | < | < (\< before a letter, /, ! or ?) |
> | > | > |
| non-breaking space | preserved as space |
Output Boundaries
- Output always ends with exactly one newline (
\n) when the input produces any content; the output is empty for empty input. - Leading blank lines that scraper adds when wrapping content in
<html><body>are trimmed before the final string is returned. - Block elements (paragraphs, headings, lists, etc.) are separated by blank lines.
Design Philosophy
The Goal: Balance, not Dominance
There are excellent HTML-to-Markdown libraries in the Rust ecosystem — some prioritise raw speed, others maximise conversion fidelity. mdka is not trying to beat them on every axis.
Its aim is a practical balance:
Produce stable, readable Markdown from real-world HTML, with an easy API, without surprising the caller at runtime.
Speed and memory efficiency matter, and mdka is designed with both in mind. But they are means to an end, not the end itself.
Real-World HTML is Messy
Web content rarely arrives as clean, well-formed documents. In practice you encounter:
- HTML that a CMS generated and no human ever wrote
- SPA-rendered DOM fragments extracted from DevTools
- Scraped pages with ad slots, cookie banners, and navigation wrapped around the content
- Documents with 5,000 levels of nested
<div>elements - Missing closing tags, duplicate attributes, and unknown elements
mdka uses scraper, which is built on html5ever — the same parser used by the Servo browser engine. It applies the HTML5 parsing algorithm, meaning: unknown elements are handled gracefully, missing tags are inferred, and the result is always a well-formed DOM tree, regardless of the input quality.
No Stack Overflows
A common failure mode in tree-processing code is stack overflow on deeply
nested input. mdka uses an explicit Vec-based stack (non-recursive DFS)
for its single tree traversal, which applies preprocessing and Markdown
conversion together in one pass. This means it handles any nesting depth
that fits in heap memory.
Configurable Pre-Processing
HTML from different sources needs different treatment. A page scraped from a news site has navigation, advertising, and footer content that a content extraction pipeline wants to remove. A document being archived for audit purposes should retain as much as possible.
The five conversion modes encode these intent differences as named, opinionated presets. They are applied inline during the single tree traversal, filtering as the DOM is walked rather than as a separate step — keeping the conversion logic itself simple and mode-agnostic.
One Allocator, Minimal Copies
The conversion pipeline is designed to minimise heap allocations:
- Whitespace normalisation is done in a single pass, writing directly into
the output
String. - No regular expressions are used at runtime (avoiding compiled regex objects).
- The output
Stringis pre-allocated with a capacity estimate. - The
#[global_allocator]counter in the CLI and benchmarks measures this directly.
Performance Characteristics
The Focus of mdka
The Rust ecosystem offers a variety of excellent HTML-to-Markdown converters. Many of these projects prioritize feature-richness, complex edge-case handling, or high extensibility.
mdka takes a different approach. Our mission is to provide a “minimalist, lightweight, and memory-efficient” converter, specifically optimized for resource-constrained environments or high-concurrency tasks where overhead must be kept to an absolute minimum.
The benchmarks presented here are not intended to rank libraries or declare a “winner.” Instead, they serve as internal metrics to verify whether mdka is successfully meeting its own design goals. We believe in choosing the right tool for the specific job, and we encourage developers to explore the diverse range of libraries available in the ecosystem to find the one that best fits their needs.
The Evolution: v1 to v2
With the release of v2, mdka underwent a complete architectural overhaul. We moved away from the original implementation to a ground-up rewrite focused on:
- Stack-Safe Traversal: Implementing a non-recursive Deep First Search (DFS) to prevent stack overflow even with deeply nested HTML.
- Optimized Memory Allocation: Reducing unnecessary clones and leveraging Rust’s ownership model to minimize peak memory usage.
- Streamlined Processing: Simplifying the conversion logic to achieve a predictable and lightweight execution path.
This rewrite resulted in a dramatic performance leap and a significantly reduced memory footprint compared to our previous version.
Benchmark Results (2026-04-15)
The following data demonstrates how the v2 architecture has improved our efficiency and how it aligns with our goal of “reasonable speed with minimal resource consumption.”
The figures below are wall-clock medians from Criterion. The log also records outliers for each run, so small differences should be read with some caution.
Conditions
All libraries were benchmarked under the same conditions:
Linux x86_64 6.19, Rust 1.94.1, Criterion 0.8, 28 logical cores, 3 s warm-up, and 3 s measurement.
Libraries Under Test
| Library | Version | HTML parser | Approach |
|---|---|---|---|
| mdka | 2.0.0 | scraper (html5ever) | Full DOM tree; non-recursive DFS |
| mdka_v1 | 1.6.9 | html5ever | Full DOM tree; older implementation |
| html2md | 0.2.15 | html5ever | DOM-based converter |
| fast_html2md | 0.0.61 | lol_html | Streaming rewriter |
| htmd | 0.5.4 | html5ever | DOM-based converter |
| html_to_markdown_rs | 3.1.0 | html5ever | DOM-based converter |
| html2text | 0.16.7 | html5ever | Text-oriented converter |
| dom_smoothie | 0.17.0 | dom_query (html5ever) | DOM-oriented converter |
These libraries do not share the same design and do have different approach and goals.
Conversion Speed
| Dataset | mdka v2 | mdka v1 | html2md | fast_html2md | htmd | html_to_markdown_rs | html2text | dom_smoothie |
|---|---|---|---|---|---|---|---|---|
| small | 131.52 µs | 131.66 µs | 132.21 µs | 79.50 µs | 90.47 µs | 107.82 µs | 350.92 µs | 317.37 µs |
| medium | 1.3040 ms | 2.2866 ms | 1.5266 ms | 887.59 µs | 1.0562 ms | 1.1660 ms | 3.3999 ms | 2.7643 ms |
| large | 12.336 ms | 75.751 ms | 12.455 ms | 7.0399 ms | 7.7896 ms | 9.6825 ms | 29.854 ms | 26.062 ms |
| deep_nest | 32.620 ms | 373.10 ms | 36.834 ms | 5.9868 ms | 72.481 ms | 96.744 ms | 30.903 ms | 29.408 ms |
| flat | 5.6253 ms | 24.817 ms | 6.7911 ms | 4.2114 ms | 5.5321 ms | 4.6975 ms | 14.023 ms | — |
| malformed | 31.712 µs | 40.178 µs | 71.778 µs | 52.948 µs | 62.302 µs | 41.109 µs | 96.822 µs | 5.6401 ms |
mdka v2 is clearly ahead of mdka v1 in this run. The gain is small on the smallest input, but it becomes much more visible as the input gets larger or structurally harder: around 1.75× faster on medium, 6.1× on large, 11.4× on deep_nest, and 4.4× on flat. On malformed input, v2 is also faster than v1 and the fastest.
Memory Allocation
| Dataset | mdka v2 | mdka_v1 | html2md | fast_html2md | htmd | html_to_markdown_rs | html2text | dom_smoothie |
|---|---|---|---|---|---|---|---|---|
| small | 113.5 KB | 240 KB | 231 KB | 154 KB | 93.6 KB | 232.5 KB | 764.5 KB | 325.4 KB |
| medium | 984.6 KB | 2.03 MB | 1.95 MB | 1.52 MB | 1.01 MB | 1.95 MB | 8.50 MB | 2.85 MB |
| large | 8.00 MB | 17.0 MB | 16.76 MB | 11.98 MB | 7.85 MB | 16.76 MB | 74.89 MB | 23.08 MB |
| deep_nest | 3.00 MB | 4.71 MB | 2.55 MB | 6.85 MB | 1.96 MB | 2.55 MB | 18.48 MB | — |
| flat | 3.93 MB | 7.90 MB | 7.87 MB | 7.46 MB | 4.84 MB | 7.87 MB | 40.28 MB | 35.47 MB |
| malformed | 44.7 KB | 91.6 KB | 71.4 KB | 145 KB | 62.3 KB | 71.4 KB | 464.4 KB | 1.63 MB |
In this run, mdka v2 uses less heap than v1.
Summary
As shown in the results, the transition to v2 has allowed us to achieve our objectives of being lightweight and memory-efficient while maintaining competitive speed.
We recognize that other libraries may offer more features or different trade-offs that make them better suited for certain applications. mdka aims to be the best choice for those who prioritize a simple, “Unix-style” tool that does one thing—conversion—with the smallest possible footprint.
Architecture
Workspace Layout
mdka/
├── src/ mdka library crate (lib only)
│ ├── lib.rs Public API surface
│ ├── options.rs ConversionMode, ConversionOptions
│ ├── traversal.rs Markdown conversion traversal
│ ├── renderer.rs MarkdownRenderer state machine
│ │ ├── sink.rs The output sink: the only writer of Markdown
│ │ └── escape.rs Escaping by context (RFC 010)
│ ├── utils.rs Tag classification helpers
│ └── alloc_counter.rs Custom allocator for benchmarks (deprecated since 2.2.2, removed in 2.4.0)
├── tests/ integration test modules
├── cli/ mdka-cli binary crate
│ └── src/main.rs Argument parsing + dispatch
├── node/ Node.js bindings (napi-rs v3)
├── python/ Python bindings (PyO3 v0)
├── benches/ criterion benchmarks
└── examples/ Allocation measurement tool
Conversion Pipeline
Each call to html_to_markdown_with follows these steps:
HTML string
│
▼
[1] Parse scraper::Html::parse_document()
│ → html5ever DOM (tolerant HTML5 parsing)
▼
[2] Traverse traversal::traverse(&doc, opts)
│ → non-recursive DFS over ego-tree, Enter/Leave events
│ Preprocessing is applied inline during this traversal:
│ · drops script/style/head/svg/… unconditionally
│ · drops shell elements when opted in
│ · unwraps generic wrappers when opted in
│ Drives MarkdownRenderer
▼
[3] Finalise renderer.finish()
→ trim trailing whitespace, single trailing newline
There is no intermediate HTML serialisation and no second parse. An earlier version of the engine preprocessed HTML into a filtered HTML string and re-parsed it before conversion; that round trip was removed, and this page now describes the single-parse, single-traversal pipeline that actually runs.
MarkdownRenderer
MarkdownRenderer is a state machine that tracks element context:
list_stack: nested ordered/unordered listsin_preand the pending code fence of the current<pre>- which open
<a>and<code>elements opened a capture
It never writes Markdown itself. Every byte goes through the output sink
(renderer/sink.rs), whose fields are private to its module, so an element
handler cannot write around it:
- Destinations. While a link’s text or an inline code span is being collected, content goes into that construct’s buffer; otherwise into the document. When the construct closes, the sink writes it into the destination it was opened in – or nothing, for a link with no text and no image or a code span with no text.
- Bookkeeping per destination:
newlines_emitted(prevents double blank lines),at_line_start, and the pending space between words. - The container prefix. Blockquotes and list items form a stack in the
sink. Every line written inside them – content, blank separator lines and
code block lines – starts with the whole stack’s prefix, outermost first:
>for a quote (>alone on a blank line), and for a list item as many spaces as its content column (-→ 2,1.→ 3). No element handler writes a prefix; the sink writes it before the first byte of a line. Line breaks are recorded and written when the next content arrives, so a blank line carries the prefix of exactly the containers still open across it. Whether a list is tight or loose is decided before rendering, by the same one pass over the document that finds inline elements around blocks. - Escaping by context (
renderer/escape.rs). Text is escaped as the sink writes it, from the line it is on – a list item’s or quote’s content starts a block after its prefix – and the character before it. An escape that depends on the character after it (1986.is a list marker only before a space;!opens an image only before[) waits, and is settled when the destination’s next byte is written, by whatever writes it: text, markup or a line break. A backslash is then inserted in front of the waiting character. Code-span captures are not escaped, and a fenced block’s opening fence is lengthened when it closes if its content holds a longer backtick run.
Inside code – an inline <code>, or a <pre> with or without <code> –
child elements contribute text only: Markdown has no emphasis, links or images
inside code, so an image there contributes nothing. A <pre> owns its fence
and produces exactly one code block, holding the text of everything inside it
in order; a <pre> without a <code> child still produces a balanced block,
and the language comes from a <code> that opens the block.
Inline elements around block content. Before rendering, the traversal makes
one bottom-up pass over the document and marks each <strong>/<b>,
<em>/<i>, <code> and <a> that has a rendered block among its
descendants. What counts as a block comes from the same classification the
renderer’s block arms use, and elements the mode skips or unwraps are left out
as the traversal itself would. A marked emphasis or code element writes no
delimiters. A marked link is written once per run of inline content between
block boundaries: ## [Title](/x). Separately, a <b>/<strong> or
<i>/<em> whose own style negates its emphasis (font-weight normal or
≤ 500, font-style: normal) writes no delimiters.
Language Bindings
Both the Node.js and Python bindings are thin wrappers:
- Node.js (napi-rs): exposes sync and async (
tokio::spawn_blocking) variants. The async variants release the Node.js event loop during conversion. - Python (PyO3): exposes
py.detach()on the batch functionhtml_to_markdown_many, releasing the GIL for rayon parallel conversion.
The binding crates (mdka-node, mdka-python) have no conversion logic
of their own — they call the same Rust functions as the library and CLI.
Features
Crash Resistance
mdka uses non-recursive DFS traversal throughout. An explicit Vec stack
replaces the call stack, so documents with arbitrarily deep nesting will
not cause a stack overflow. This has been tested with 10,000 levels of
nested <div> elements.
Some fast converters use recursive tree traversal and will crash on deeply nested input. If your input source is not fully controlled, crash resistance matters.
Five Conversion Modes
Rather than a single fixed conversion strategy, mdka offers five named modes that tune the pre-processing pipeline:
- Balanced — readable output for general use
- Strict — maximum attribute retention for debugging
- Minimal — body text only; good for LLM input preparation
- Semantic — preserves ARIA and document structure
- Preserve — maximum fidelity for archiving
Each mode can be further customised with per-call option flags. See Conversion Modes and ConversionOptions.
Parallel File Conversion
html_files_to_markdown and html_files_to_markdown_with use
rayon to convert multiple files
in parallel. Each file’s result is independent — one failed file does
not stop the batch.
The Node.js and Python bindings expose this as an async function
(htmlFilesToMarkdown, html_files_to_markdown) so the thread pool
work does not block the caller’s event loop or hold the GIL.
Multi-Language API
The same Rust implementation is accessible from three languages:
| Language | Package | Mechanism |
|---|---|---|
| Rust | mdka on crates.io | native library |
| Node.js | mdka on npm | napi-rs native module |
| Python | mdka on PyPI | PyO3 extension module |
All three call the same underlying conversion code and produce identical output for identical input.
html5ever Parser Foundation
The HTML parser is scraper, which is built on html5ever. html5ever implements the HTML5 parsing algorithm, the same one that web browsers use.
This means:
- Missing closing tags are inferred correctly
- Unknown elements are preserved (not silently dropped)
- Malformed attribute syntax is normalised
- The result is always a valid DOM tree, no matter the input
Predictable, Deterministic Output
For a given HTML input and ConversionOptions, mdka always produces
the same Markdown string. There is no randomisation, no date-stamping,
and no version-dependent output variation within a semver major version.
Minimal Dependencies
The runtime dependencies of the mdka library crate are:
| Crate | Purpose |
|---|---|
scraper | HTML parsing (html5ever wrapper) |
ego-tree | DOM tree traversal |
rayon | Parallel file conversion |
tikv-jemallocator, tikv-jemalloc-ctl | Ensures fragmentation avoidance and scalable concurrency |
thiserror | MdkaError derive macro |
Benchmark and comparison dependencies (criterion, competitors) are
[dev-dependencies] and do not affect library consumers.