HTML-to-Markdown GitHub Projects: Pick a Fit
Choose an HTML-to-Markdown GitHub project by its owner, runtime and conversion rules. Classify your input into three cases: a whole page whose navigation needs removing, a table whose header meaning must survive, or an export whose code needs a language label. Our local comparison found a table that kept every header word but lost the delimiter that makes it Markdown. That failure affected a Python library and the package that wraps it. 5611
Classify the HTML before choosing a repository
Whole pages, tables and code-bearing exports need separate rules for article boundaries, headers and language labels.
| Input feature | How to identify the requirement | Conversion route |
|---|---|---|
| Whole web page | The HTML contains navigation, a cookie banner or a footer outside the article | Select the article region, or use a path that excludes those elements on your sample |
| Data table | Read the first row's tags: header cells, ordinary data cells, or a header after a column group | Choose an explicit header policy and inspect the delimiter row |
| Code-bearing export | The code element has a language class that your Markdown output needs | Preserve that class through a callback or a writer that carries it over |
A page can meet several rows of this table. Our article sample contained page furniture, a labelled table and SQL code together. Removing the cookie banner would not repair a missing table delimiter, and retaining code text would not retain its language label. 11
Identify HTML-to-Markdown GitHub projects by owner
The repository owner and dependency graph identify the conversion rules you are installing.
| Repository | Runtime or role | What matters before adoption |
|---|---|---|
| JohannesKaufmann/html-to-markdown | Go library and CLI | Tables are an additional plugin in its documented configuration. Not executed for this guide 1 |
| xberg-io/html-to-markdown | Rust core with language bindings | A separate implementation despite the same repository name. Not executed for this guide 2 |
| mixmark-io/turndown | JavaScript HTML conversion | GFM tables come from a plugin whose heading-row filter affects conversion. Not executed for this guide 34 |
| matthewwithanm/python-markdownify | Python tag conversion | Executed with explicit heading style, region selection and header-policy controls 712 |
| microsoft/markitdown | Multi-format Python entry point | Executed on HTML; its HTML converter delegates to a Markdownify subclass 616 |
| jgm/pandoc | Document reader and writer | Executed with the HTML reader and GFM writer 912 |
The unexecuted rows identify candidates for those runtimes. They do not inherit the results of the Python or Pandoc runs. A package's repository owner, its dependency lock and its enabled plugins belong in the integration record: “HTML-to-Markdown” alone cannot identify the code that produced an output.
Choose a header policy before enabling tables
A table converter needs a header policy and a working delimiter rule.
GFM represents a table with a header followed by a delimiter row. HTML can contain a table made entirely of data cells, so the converter must decide how to express the absent header. Markdownify leaves an empty header by default. Its inference option promotes the first data row instead. 78
Output from our headerless data sample, unedited:
| | |
| --- | --- |
| Amber | Queued |
| Birch | Done |
Output from our headerless data sample with inference enabled, unedited:
| Amber | Queued |
| --- | --- |
| Birch | Done |
The second output gives a task name and its state the role of column labels. Enable inference only when the first row contains labels that were encoded as ordinary data cells. If it contains a record, preserve the empty header or supply labels from the source schema. 711
An explicit HTML header can also fail because of its position. Our column-group sample put a colgroup element before a row of th cells, without a thead wrapper. Markdownify and MarkItDown retained the words but omitted the delimiter. 1113
Output from our column-group header sample, unedited:
| Task | State |
| Amber | Queued |
| Birch | Done |
Markdownify's row converter requires the header row to have no preceding sibling element before it emits the delimiter. The column group violates that condition. Wrapping the same header row in thead restored the delimiter in our control. Turning on header inference did not restore it. 51114
| Symptom | Distinguishing output or input feature | Response supported by this comparison |
|---|---|---|
| A task name becomes a column label | The source's first row contains data cells, and its content appears above the Markdown delimiter | Disable header inference or supply genuine labels |
| Header words survive but the table is plain text | The delimiter is absent and a column group precedes the header row | Put the header in an explicit header section, then compare that output |
| An issue describes a similar failure | Its input contains different header markup or an unspecified library version | Treat it as a reproduction lead, not evidence that your version fails |
The Turndown plugin's historical empty-header report illustrates the last distinction. Its example contained an empty header cell, rather than our column group or all-data table. We did not run that JavaScript implementation, so the report cannot establish its output here. 10
Configure the Python path you already use
Markdownify exposes the rules directly, while MarkItDown inherits its HTML table behavior.
For a known article template, select the article before converting it and extract the language from the code element. This example stops if the expected region is absent. A fallback to the entire page would put navigation back into the document. 7
from pathlib import Path
from bs4 import BeautifulSoup
from markdownify import markdownify
def code_language(pre):
code = pre.find('code')
for value in (code or pre).get('class', []):
if value.startswith('language-'):
return value.removeprefix('language-')
return ''
soup = BeautifulSoup(Path('input.html').read_text(), 'html.parser')
article = soup.select_one('main article')
if article is None:
raise ValueError('No main article element in this input')
Path('output.md').write_text(markdownify(
str(article), heading_style='ATX', code_language_callback=code_language))
Output from our article sample with region selection and the callback, unedited:
```sql
SELECT pid, wait_event_type, query
FROM pg_stat_activity
WHERE state = 'active';
```
The callback restored the SQL label that the unconfigured conversion omitted. It relies on a language class in the source and does not infer a language from the code itself. The selector removed the page furniture on this template. Neither setting repaired the column-group table, because both operate outside the table-row rule. 1112
MarkItDown is useful when HTML belongs to a workflow with other document formats and you want a shared entry point. Its HTML path removes script and style elements, then converts the body through its Markdownify subclass. On our full-page input, that boundary included the cookie banner and navigation. 616
from pathlib import Path
from markitdown import MarkItDown
result = MarkItDown(enable_plugins=False).convert('input.html')
Path('output.md').write_text(result.text_content)
Output from our full-page sample through MarkItDown, unedited:
We use cookies to improve your experience.
Engineering notes from the infrastructure team
That wrapper also reproduced the column-group delimiter failure. Apply a source-structure correction or choose an independently tested writer when that rule blocks your input. 11
Markovo's archived HTML run offers a hosted comparison for the article boundary: it excluded the banner and navigation while keeping the article, but its code fence lost the SQL language label. The successful job therefore did not establish complete code preservation. The archive predates this local comparison and covers only the article baseline. For a hosted public URL to Markdown route, the product supports publicly accessible HTTPS pages in Signed-in Beta. This uploaded-HTML result does not establish URL-fetching behavior, access to protected pages or GitHub rendering. The added header probes were not run through the service. 1115
Output from our archived hosted article sample, unedited:
# Debugging Slow Database Migrations
A migration that runs in seconds on a laptop can hold locks for minutes in production. The difference usually comes from table size and from other sessions waiting behind the migration.
Use Pandoc when you want a GFM writer
Pandoc retained the delimiter that our Python outputs dropped on the column-group input.
Use an explicit input reader and output writer so the command records the format decision. This command converts a local file and performs no browser rendering. 9
pandoc -f html -t gfm --wrap=none input.html -o output.md
Output from our column-group header sample through Pandoc, unedited:
| Task | State |
|-------|--------|
| Amber | Queued |
| Birch | Done |
Pandoc kept the delimiter for the column-group input without the header wrapper. On the full-page sample it also kept the SQL language label and excluded the page furniture. Those observations describe the supplied HTML. They do not establish an article extractor for arbitrary page layouts. Pandoc parses into an intermediate document representation before writing Markdown, and that representation can lose details from richer input. 911
| Boundary | What the output can hide | Decision |
|---|---|---|
| Header inference | A record may become a heading even though all words remain | Decide whether the first source row is labels or data |
| Code fences | Code text can survive without its language | Compare the source class with the emitted fence label |
| Whole-page conversion | Valid Markdown can contain navigation and banners | Define the region you want before integrating a converter |
| GFM output | Local conversion says nothing about platform post-processing | Treat GitHub.com rendering as a separate integration check 8 |
For a Python pipeline with a known DOM template, Markdownify lets you state the article boundary and language handling in code. MarkItDown keeps that HTML table dependency while offering a common document entry point. Pandoc supplied a separate GFM-writing path for the structural case that failed in our Python outputs. For Go or JavaScript integration, use the owner and plugin map to identify a candidate, then apply the same header, region and language requirements to that implementation. 1311
How we tested
The comparison covers local HTML conversion, with a separately dated Markovo baseline.
The local runs took place on 2026-10-27 using Python 3.11.14, MarkItDown 0.1.8, Markdownify 1.2.3, Beautiful Soup 4.15.0 and Pandoc 3.11. All Markdownify profiles explicitly used ATX headings. The selected profile added article selection and a code-language callback. The inference profile enabled table_infer_header=True. These profiles are controls of one library, and MarkItDown also uses that library for HTML. They are not independent engines. 6712
Markovo is the service we build. It ran through the same corpus and scorer as the other paths; its row is reported in full, including where it scored lower.
The shared Markovo corpus here is only article.html. Its API snapshot records a succeeded job on 2026-10-14 with pages_total=1 and pages_ocr=0. We reused the downloaded Markdown without another API call. The snapshot does not identify the conversion-engine version, and its rate-card identifier is not such a version. The added header probes were not executed on Markovo. Its output loses the SQL language label, a property not measured by the code-text score. No timing or service-wide quality comparison follows from this archive. 15
| Input | Shape and purpose | Publishable input evidence |
|---|---|---|
article.html |
Full page with navigation, banner, footer, article, flat table and language-labelled SQL | Exact text mirror |
headerless.html |
All-data table with no header cells | Exact text mirror |
colgroup-header.html |
Column group before explicit header cells, without a header-section wrapper | Exact text mirror |
colgroup-thead.html |
Same visible table, with only the header row wrapped in thead |
Exact text mirror |
Each input has a same-stem .truth.json. The upstream full-page input was reused unchanged. The runner creates the other inputs and byte-identical .txt mirrors. The mirrors are evidence files, not rendered HTML. No PDF, scan, Office, GitHub.com or VS Code test is represented by this HTML subset. 12
From this repository root, with the existing offline environment:
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-10-27-markovo-html-to-markdown-github/lab/run_html.py
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python tools/lab_score.py --lab runs/2026-10-27-markovo-html-to-markdown-github/lab --out runs/2026-10-27-markovo-html-to-markdown-github/lab/scores.json
The runner records all options and commands in the execution manifest. It runs MarkItDown and all Markdownify profiles on every selected input, and calls Pandoc with -f html -t gfm --wrap=none. The strict standalone examples were also executed with the article input: Markdownify example and MarkItDown example. Turndown, the Go implementation and the Rust implementation were not executed for this guide. 1012
The table below copies the selected cells from scores.json. P is prose_recall, the fraction of truth sentences of at least four words found after normalization. O is reading_order, adjacent paragraph anchors occurring in order. H is heading_level_exact, expected ATX headings at the expected level. R is table_rows_intact, each row's cell strings found together on an output line. T is table_header_intact, header strings found on one line. Pipe is table_pipe_detected, a pipe row followed by a delimiter. C is code_block_intact, expected code lines found within fences. B is boilerplate_excluded, absent navigation/banner/footer truth strings. Fractions are not a composite quality score. 11
| Profile | Input | P | O | H | R | T | Pipe | C | B | Seconds |
|---|---|---|---|---|---|---|---|---|---|---|
| markitdown | article | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | yes | 1.0 | 0.0 | 0.006645 |
| markitdown | headerless | 1.0 | N/A | 1.0 | 1.0 | N/A | yes | N/A | N/A | 0.002876 |
| markitdown | colgroup-header | 1.0 | N/A | 1.0 | 1.0 | 1.0 | no | N/A | N/A | 0.003805 |
| markitdown | colgroup-thead | 1.0 | N/A | 1.0 | 1.0 | 1.0 | yes | N/A | N/A | 0.002395 |
| markdownify | article | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | yes | 1.0 | 0.0 | 0.000866 |
| markdownify | headerless | 1.0 | N/A | 1.0 | 1.0 | N/A | yes | N/A | N/A | 0.00023 |
| markdownify | colgroup-header | 1.0 | N/A | 1.0 | 1.0 | 1.0 | no | N/A | N/A | 0.000367 |
| markdownify | colgroup-thead | 1.0 | N/A | 1.0 | 1.0 | 1.0 | yes | N/A | N/A | 0.000319 |
| markdownify-selected | article | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | yes | 1.0 | 1.0 | 0.003266 |
| markdownify-selected | headerless | 1.0 | N/A | 1.0 | 1.0 | N/A | yes | N/A | N/A | 0.000368 |
| markdownify-selected | colgroup-header | 1.0 | N/A | 1.0 | 1.0 | 1.0 | no | N/A | N/A | 0.00048 |
| markdownify-selected | colgroup-thead | 1.0 | N/A | 1.0 | 1.0 | 1.0 | yes | N/A | N/A | 0.000552 |
| markdownify-infer-header | article | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | yes | 1.0 | 0.0 | 0.000801 |
| markdownify-infer-header | headerless | 1.0 | N/A | 1.0 | 1.0 | N/A | yes | N/A | N/A | 0.000202 |
| markdownify-infer-header | colgroup-header | 1.0 | N/A | 1.0 | 1.0 | 1.0 | no | N/A | N/A | 0.000268 |
| markdownify-infer-header | colgroup-thead | 1.0 | N/A | 1.0 | 1.0 | 1.0 | yes | N/A | N/A | 0.000337 |
| pandoc-gfm | article | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | yes | 1.0 | 1.0 | 0.048633 |
| pandoc-gfm | headerless | 1.0 | N/A | 1.0 | 1.0 | N/A | yes | N/A | N/A | 0.047002 |
| pandoc-gfm | colgroup-header | 1.0 | N/A | 1.0 | 1.0 | 1.0 | yes | N/A | N/A | 0.084978 |
| pandoc-gfm | colgroup-thead | 1.0 | N/A | 1.0 | 1.0 | 1.0 | yes | N/A | N/A | 0.108589 |
| markovo | article | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | yes | 1.0 | 1.0 | archive |
| markovo | headerless | NT | NT | NT | NT | NT | NT | NT | NT | NT |
| markovo | colgroup-header | NT | NT | NT | NT | NT | NT | NT | NT | NT |
| markovo | colgroup-thead | NT | NT | NT | NT | NT | NT | NT | NT | NT |
N/A means an absent measurement, except headerless T: the scorer emits zero for the absent truth header, which we mark N/A rather than call a missing header. Markovo NT means not tested on that input. The archived elapsed value is a placeholder, not a measured duration. Local seconds are single executions, not a throughput benchmark.
These checks do not measure semantic column alignment, language labels, merged-cell meaning, link reachability or rendered appearance. The inference profile still receives full row-text credit after promoting data into a header. Likewise, the column-group failure retains header-text credit while failing Pipe. Raw output inspection and the installed function snapshot explain those differences. 51114
Raw outputs use lab/<profile>/<input-stem>.md, including lab/markdownify/colgroup-header.md, lab/markdownify/colgroup-thead.md, lab/markdownify-infer-header/headerless.md, lab/markdownify-selected/article.md, lab/markitdown/article.md, lab/pandoc-gfm/colgroup-header.md and lab/markovo/article.md. Corpus notes distinguish upstream files, local controls and unmeasured properties. 12
Sources
- Go html-to-markdown README
- Rust-core html-to-markdown README
- Turndown GFM table rules
- Turndown README
- Markdownify table and code conversion source
- MarkItDown HTML converter source
- Markdownify options
- GitHub Flavored Markdown specification
- Pandoc User's Guide
- Historical empty-header conversion report
- Full scorer output
- Offline HTML conversion runner
- Publishable colgroup input mirror
- Installed converter function snapshot and hash
- Archived Markovo API response
- MarkItDown Markdownify subclass


