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

  1. Go html-to-markdown README
  2. Rust-core html-to-markdown README
  3. Turndown GFM table rules
  4. Turndown README
  5. Markdownify table and code conversion source
  6. MarkItDown HTML converter source
  7. Markdownify options
  8. GitHub Flavored Markdown specification
  9. Pandoc User's Guide
  10. Historical empty-header conversion report
  11. Full scorer output
  12. Offline HTML conversion runner
  13. Publishable colgroup input mirror
  14. Installed converter function snapshot and hash
  15. Archived Markovo API response
  16. MarkItDown Markdownify subclass