Skip to main content

xhtmlmd

A Rust Markdown parser and XHTML renderer.

The parser is tree-oriented. It preserves the structure and attributes needed for XHTML output, but it does not try to round-trip source text. The dialect is CommonMark/GFM for the core and GFM features, with Pandoc-leaning choices where extension families disagree.

xhtmlmd is largely implemented using AI, except for the tests. The tests are largely adapted from cmark-gfm, PHP Markdown Extra, kramdown, Pandoc, and Mistlefoot. Credit for xhtmlmd really belongs to the authors of these tests, and of the CommonMark docs, which is where the hard work was done.

Implemented syntax

  • Core block syntax: paragraphs, ATX/setext headings, thematic breaks, block quotes, ordered/unordered lists, indented code, raw HTML, link reference definitions.
  • Tables: GFM/PHP Extra pipe tables with alignment, and Pandoc grid tables with alignment, headerless tables, block cell content, row spans, column spans, and footers.
  • GFM: task lists, ~~x~~ strikethrough, angle and bare autolinks, plus opt-in tagfiltering.
  • Code: backtick/tilde fenced code blocks, info strings, and Pandoc-style code attributes.
  • HTML-in-Markdown: block containers opened with markdown="1"; the control attribute is stripped, indented code blocks are disabled inside the container, and fenced code is the code-block syntax there.
  • Math: four modes: brackets for \(...\), \[...\], and $$...$$, dollars for those plus $...$ using Pandoc's non-space/digit dollar rules, on to preserve \(...\) and \[...\] delimiters for client-side renderers such as KaTeX, and off. Brackets mode is the default.
  • Attributes and inline spans: Pandoc/kramdown-style {#id .class key="value"}, block IALs {: ...}, span IALs, ALDs such as {:note: #id .class} with references, superscript ^x^, subscript ~x~, and highlight ==x==.
  • Definition lists: PHP Markdown Extra/Pandoc-style Term followed by : definition or ~ definition.
  • Footnotes: [^id] references to defined [^id]: definitions with indented continuation blocks.
  • Abbreviations: *[HTML]: Hyper Text Markup Language definitions render matching text as <abbr>.
  • Fenced divs: Pandoc/Quarto/Djot-style ::: containers with attributes or a single class word.
  • Raw passthrough: a Pandoc-style raw attribute names the format a payload is written for. A fenced code block whose info string is exactly {=name}, or inline code followed immediately by {=name}, renders as <script type="text/x-name"> with the payload entity-escaped. The parser never inspects the name or the payload; each downstream converter documents which names it consumes and ignores the rest.
  • Cross-references: Quarto-style bracketed references to identified elements. [@sec-pay] renders as <a data-ref="data-ref" href="#sec-pay"></a>, a symbolic carrier each converter resolves its own way (a number, a Word REF field, a link); [-@sec-pay] marks it bare (data-ref="bare", no prefix word), [Clause @sec-pay] carries override text, and [@sec-a; @sec-b] groups references in a span.refs. A trailing attribute list attaches as usual, e.g. [-@sec-pay]{ref=page}. The bracket group must contain only reference items (prefix text needs a space before the @, and ids are letters, digits, -, _), it loses to explicit link syntax such as [@sec-x](url), and anything that doesn't match stays ordinary text, so [user@host] is untouched. The parser never resolves numbers or checks that targets exist.
  • Table captions and figures: a : caption {attrs} line glued directly under a table's last row captions it (attrs apply to the table; Quarto's caption format, glued-only and after-only in ours), and a paragraph that is exactly one image becomes a <figure> with the alt text as <figcaption> (pandoc's implicit figures; the image's id and classes move to the figure).
  • Inline footnotes: pandoc-style ^[an inline note], numbered together with [^id] references.
  • Smart punctuation (opt-in smart=True): --- and -- to em and en dashes, ... to an ellipsis, and quote curling, in text only; code, math, and raw payloads are untouched.

Attributes

A braced group is an attribute list only when it starts with :, #, ., or a key=value pair. Anything else in braces is ordinary text, so prose like use {braces} freely keeps its content. The marker forms follow Pandoc: {#id .class key="value"}. The colon form follows kramdown: {:note} and {: note} apply the attribute definition named note, and an unknown name in a colon-marked list is ignored while the list itself is still consumed.

ALDs (attribute list definitions) are kramdown's named bundles. {:note: #id .class} on its own line defines note; a reference resolves either as a colon-marked list ({:note}) or as a bare token inside a list already recognized by its markers ({.x note}).

Attribute lists attach to:

  • Headings, ATX and setext: # Head {#h}. Headings without an explicit id get a pandoc-style one derived from their text (lowercased, punctuation dropped, spaces to hyphens, -1 suffixes on duplicates); pass auto_ids=False to disable.
  • Fenced code: in the info string, python {.numberLines} after the opening fence.
  • Fenced divs: in the ::: opener.
  • Tables: a trailing list on the glued : caption line applies to the table.
  • Link reference definitions: [r]: /url "title" {.external} applies the attributes to every link resolved through that reference.
  • Any block, via a standalone IAL line {: ...}. IALs bind by adjacency: glued directly under a block (including the last row of a table) they modify it, glued directly above a block they modify that one, and an isolated IAL with blank lines on both sides is literal text. This is also the only way to attribute a paragraph; a brace group at the end of a paragraph's own text is always literal.
  • Inline constructs, when the list follows immediately with no space: spans [x]{.c}, links, images, code spans, emphasis, strong, strikethrough, superscript, subscript, highlight, and math.

Raw HTML blocks take no attribute lists; write attributes in the HTML itself.

Usage

Install via pip to get both the Python API and the native xhtmlmd CLI:

pip install xhtmlmd

The CLI reads Markdown from stdin or from an optional file path and writes an XHTML fragment to stdout:

echo '# Hello' | xhtmlmd
xhtmlmd input.md > out.xhtml
xhtmlmd --math=on input.md > out.xhtml
xhtmlmd --math=dollars input.md > out.xhtml

Python API:

from xhtmlmd import to_xhtml

html = to_xhtml(r"\(x^2\)")
html_for_katex = to_xhtml(r"\(x^2\)", math="on")
html_with_dollars = to_xhtml("$x$", math="dollars")

Markdown rewriting

rewrite changes recognized Markdown constructs without regenerating the rest of the document. A callback returns None to leave a construct alone, a string to replace the whole construct, or a dict to replace one of its named fields.

This converts inline dollar math to bracket math:

from xhtmlmd import rewrite

def bracket_math(node):
    if node["delimiter"] != "$": return None
    return rf"\({node['tex']}\)"

markdown = rewrite(markdown, {"math_inline": bracket_math}, math="dollars")

An image callback can save a data URL and replace only its destination. The alt text, title, attributes, and original spacing are preserved.

from base64 import b64decode
from pathlib import Path
from xhtmlmd import rewrite

def save_image(node):
    if not node["url"].startswith("data:image/png;base64,"): return None
    path = Path("images/plot.png")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(b64decode(node["url"].split(",", 1)[1]))
    return {"url": path.as_posix()}

markdown = rewrite(markdown, {"image": save_image})

Callbacks run in source order. Their edits are checked first and then applied from the end of the document, so an early replacement cannot invalidate a later source position. Exceptions from callbacks are passed through unchanged.

Every callback node is a dict with these common fields:

  • type: callback name, currently image or math_inline.
  • source: the exact source text for the construct.
  • start, end: half-open character offsets into the original Python string.

An image node has:

  • form: currently always inline.
  • alt: plain alt text.
  • url: the decoded image destination.
  • title: decoded title text, or None.

An image callback may return {"url": "new destination"}. Other image fields are read-only. Reference-style images such as ![alt][id] are not callback targets.

A math_inline node has:

  • delimiter: $, $$, \(, or \[.
  • tex: content without delimiters.
  • display: True for $$ and \[, otherwise False.

A math callback may return {"tex": "new TeX"} to preserve the delimiters, or a string to replace the entire construct. Dollar math is recognized only with math="dollars", using the same dollar rules as rendering.

Rewriting is confined to inline-capable prose regions. Inline code, fenced and indented code blocks, raw HTML blocks, block math, link reference definitions, and grid tables are left untouched. Inline images and math inside paragraphs, headings, lists, block quotes, definition bodies, footnotes, and pipe tables are supported.

Callbacks

Python callers can override rendered nodes with callbacks. Each callback receives a node dict and the default XHTML for that node. Return None to keep the default, or return replacement XHTML.

Callback names:

  • Blocks: paragraph, heading, block_quote, list, definition_list, code_block, html_block, html_container, thematic_break, table, div, math_block
  • Inlines: text, soft_break, hard_break, emph, strong, strike, superscript, subscript, highlight, code, link, image, autolink, abbr, html_inline, math_inline, footnote_ref, span
from fastpylight import highlight
from xhtmlmd import to_xhtml

def highlight_code(node, default_html):
    if node["lang"] != "python": return None
    return highlight(node["text"], node["lang"]) + "\n"

html = to_xhtml(markdown, callbacks={"code_block": highlight_code})

Callbacks can also render bracket math as MathML:

from math_core import LatexToMathML
from xhtmlmd import to_xhtml

mathml = LatexToMathML()

def render_math(node, default_html):
    html = mathml.convert_with_local_state(node["tex"], displaystyle=node["type"] == "math_block")
    return html + ("\n" if node["type"] == "math_block" else "")

html = to_xhtml(markdown, callbacks={"math_inline": render_math, "math_block": render_math})

Block spans

blocks reports where each top-level block sits in the source, so callers can split a document into per-block source slices without regenerating Markdown from a tree. Each dict has type (the callback names above, plus link_ref, abbr_def, attr_def, and footnote_def) and half-open 0-based start/end line indices; code and math blocks also carry their inner text, and fences carry info/lang.

from xhtmlmd import blocks

src = open("input.md").read()
lines = src.split("\n")
for b in blocks(src):
    print(b["type"], "\n".join(lines[b["start"]:b["end"]]))

Command-line usage (the xhtmlmd script is installed with the package):

xhtmlmd input.md > out.xhtml
cat input.md | xhtmlmd --math=dollars

Parsing strategy

The parser uses the two-phase strategy described in the CommonMark parsing-strategy appendix: first build the block tree and collect link reference definitions, then parse raw inline text with the completed reference table. It tracks visual columns and byte offsets for each line and builds blocks with an arena-backed open-container stack. The stack has typed nodes for block quotes, lists, paragraphs/setext candidates, fenced and indented code, raw HTML, table candidates, grid tables, math, footnote definitions, definition lists, fenced divs, and markdown-in-HTML containers. Inlines are scanned into atoms, bracket openers, and delimiter runs; links/images/spans resolve through the bracket stack, while emphasis/strong/strikethrough resolve through the delimiter stack. Inputs that can otherwise explode have explicit bounds: inline nesting, block/container nesting, link label length, and link parenthesis nesting.

The link parser uses raw reference-label scanning, bounded parenthesis nesting, bounded link labels, URI escaping for rendered href/src attributes, and a plain-text fast path for inputs with no possible inline constructs. This keeps adversarial inputs such as deeply nested brackets, long blockquote runs, repeated ![[](), and unclosed comments in predictable time.

Raw HTML is preserved by default. Supported raw HTML container tags such as div, section, table, svg, math, and custom elements stay open across blank lines until their matching close tag, with same-tag nesting counted; void and self-closing tags do not open balanced containers. Markdown inside raw HTML remains raw unless the open tag that starts the Markdown block uses markdown="1"; this crate does not recursively look for markdown controls inside otherwise-raw HTML. Options::default().tagfilter is false; enabling it applies GFM-style filtering for tags such as script, style, xmp, and textarea. This is compatibility and extra protection, not a replacement for sanitizing untrusted rendered HTML.

Raw HTML passthrough means unbalanced source HTML produces an unbalanced fragment, exactly as CommonMark specifies. The opt-in balance option (Options::default().balance is false; --balance on the CLI) restores well-formedness after rendering: unclosed elements are closed at the end of the fragment, stray closing tags are dropped, a closing tag that skips over open elements closes them first, void elements are rewritten to self-closing form, and rawtext elements such as script are copied verbatim to their real close. It deliberately does not apply HTML5 implied-end-tag rules (no <p> auto-close) or rewrite attributes.

Tests

maturin develop && pytest -q

The spec-conformance suite is tests/test_conformance.py: it renders the fixtures under tests/source/ and compares normalized HTML trees. Run just that file with pytest tests/test_conformance.py -v to see per-example ids.

Release files for xhtmlmd 0.1.12

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xhtmlmd 0.1.12
File Size Uploaded
xhtmlmd-0.1.12.tar.gz 162.1 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for xhtmlmd 0.1.12
File
xhtmlmd-0.1.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.13 CPython 3.13 Linux glibc 2.17+ x86-64 Details
xhtmlmd-0.1.12-cp313-cp313-macosx_11_0_arm64.whl CPython 3.13 CPython 3.13 macOS 11.0+ ARM64 Details
xhtmlmd-0.1.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.12 CPython 3.12 Linux glibc 2.17+ x86-64 Details
xhtmlmd-0.1.12-cp312-cp312-macosx_11_0_arm64.whl CPython 3.12 CPython 3.12 macOS 11.0+ ARM64 Details
xhtmlmd-0.1.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.11 CPython 3.11 Linux glibc 2.17+ x86-64 Details
xhtmlmd-0.1.12-cp311-cp311-macosx_11_0_arm64.whl CPython 3.11 CPython 3.11 macOS 11.0+ ARM64 Details
xhtmlmd-0.1.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.10 CPython 3.10 Linux glibc 2.17+ x86-64 Details
xhtmlmd-0.1.12-cp310-cp310-macosx_11_0_arm64.whl CPython 3.10 CPython 3.10 macOS 11.0+ ARM64 Details

Total release size: 4.1 MB

Release files / xhtmlmd-0.1.12.tar.gz

Download URL xhtmlmd-0.1.12.tar.gz
Size 162.1 kB
Tags Source
SHA-256 checksum
How to use checksums
b72ccc258475e37207ca09b36be254c2796fd7864dea90d631a69e5b035e12df
BLAKE2b-256 checksum
How to use checksums
d627b25b8d113516caa9884b0f17ca40f827cd4658bc0c09521e5739898b069e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL xhtmlmd-0.1.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 520.6 kB
Tags CPython 3.13 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
3dd55bef8bcedf7d3462aff690985b3b05ea1d9ea2434359e2bca846b357d92f
BLAKE2b-256 checksum
How to use checksums
b831be2ebf56d7f4b0c3dfab65ae583c538a9abaf1e150be1b4824d9b73e2bb3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp313-cp313-macosx_11_0_arm64.whl

Download URL xhtmlmd-0.1.12-cp313-cp313-macosx_11_0_arm64.whl
Size 468.6 kB
Tags CPython 3.13 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
df00afc22348919d183b2b305705b7e294161c8608937539a8bced800c264d00
BLAKE2b-256 checksum
How to use checksums
92f7aa81ab9454712b57b47ba0a38a0f181cff75dbadbace2b557798547c56e2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL xhtmlmd-0.1.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 520.8 kB
Tags CPython 3.12 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
209aa2104b2ecd512e7e0afc1894a204f921ce9b896538292175cafd7cd57295
BLAKE2b-256 checksum
How to use checksums
8ce4f3281223f279a39a415ba5ae407a194352aa846fb1b5f426f3125383d201
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp312-cp312-macosx_11_0_arm64.whl

Download URL xhtmlmd-0.1.12-cp312-cp312-macosx_11_0_arm64.whl
Size 468.7 kB
Tags CPython 3.12 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
8559bb758669492963eaaeaaf70cdbd51dfff2fded6391e7c55474b8c8e62d1b
BLAKE2b-256 checksum
How to use checksums
e9437ef8fe4996be16af91d56f10f6a0af19ecdf3fd1acdd92a0d5726065e132
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL xhtmlmd-0.1.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 521.7 kB
Tags CPython 3.11 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
b9c018f8d4abe29777998abb6447911bdd22598b23c2529cee111232caef7d2b
BLAKE2b-256 checksum
How to use checksums
55a773a5661abfc0ed546d2e153653e3db8210984c7f398b51429a5ee6d7a96f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp311-cp311-macosx_11_0_arm64.whl

Download URL xhtmlmd-0.1.12-cp311-cp311-macosx_11_0_arm64.whl
Size 470.8 kB
Tags CPython 3.11 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
5f3d166903628189ebd46cbdf1c248c3e81f6d0163a22bed50a88644aba1a98f
BLAKE2b-256 checksum
How to use checksums
8b39d8ce008035df92284c9e7456f257c55db97f871f6ed5c9abf03fadc99475
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL xhtmlmd-0.1.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 522.3 kB
Tags CPython 3.10 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
e27a56b5e1c17e00ec297e5088f91060768c8ede0826c03a45ec6905b3a42a0b
BLAKE2b-256 checksum
How to use checksums
b954b8b4b4e6d6d0fdbbb41233d00e53686266e8c29a9418b741e1585d31d7a4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release files / xhtmlmd-0.1.12-cp310-cp310-macosx_11_0_arm64.whl

Download URL xhtmlmd-0.1.12-cp310-cp310-macosx_11_0_arm64.whl
Size 471.1 kB
Tags CPython 3.10 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
ccc1c323297fc061de9621a866e911d906c142262489652885af27f9deb6f146
BLAKE2b-256 checksum
How to use checksums
4104d37de2e1700c060657dbe19eaac3cdfc9bd7f910cf01a644c9067d98e491
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 20, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.12 This release

9 release files

0.1.11

9 release files

0.1.10

9 release files

0.1.9

9 release files

0.1.6

9 release files

0.1.5

9 release files

0.1.4

9 release files

0.1.3

9 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page