<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Rust · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/rust/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Thu, 22 Oct 2026 14:42:37 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/rust/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>A Closed Pull Request, a Fork and a PyPI Package: Fast HTML to Markdown for Python</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Rust</category>
      <category>PyPI</category>
      <category>Open Source</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>Performance</category>
      <description>I needed relative links resolved when converting HTML to Markdown for AI agents. The upstream Rust crate couldn&#39;t take my pull request because it was written with an LLM, so I forked it and published pyhtml2text: Python bindings with base_url, a new html2markdown, profile-guided wheels, and a patched parser that handles deeply nested HTML in linear time. Here&#39;s why it exists, what&#39;s in it, and how I checked the output.</description>
      <content:encoded><![CDATA[<p>On 15 October I published <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a> on PyPI. It converts HTML to plain text or Markdown and is built on <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>, Chris Emerson&rsquo;s Rust crate. Converting to Markdown, it was between 3 and 21 times faster than the usual Python converters on the pages I measured. It can also resolve relative links against the page&rsquo;s URL, so the links in the output still work once they&rsquo;re away from the page.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;&lt;h2&gt;Before you start&lt;/h2&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;p&gt;Read the &lt;a href=&#34;setup.html&#34;&gt;setup guide&lt;/a&gt; and &lt;b&gt;back up&lt;/b&gt; your data.&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;img src=&#34;https://jared.lynskey.co.nz/img/diagram.png&#34; alt=&#34;How it fits together&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Basic&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Before you start
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Read the [<span class="nt">setup guide</span>](<span class="na">https://example.com/docs/setup.html</span>) and <span class="gs">**back up**</span> your data.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">![<span class="nt">How it fits together</span>](<span class="na">https://example.com/img/diagram.png</span>)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">| Plan | Price |
</span></span><span class="line"><span class="cl">| --- | --- |
</span></span><span class="line"><span class="cl">| Basic | Free |</span></span></code></pre></div></div>
<p>It started as a pull request to rust-html2text. The maintainer closed it because it was written with an LLM, so pyhtml2text runs on a fork instead. This post covers how that happened, what&rsquo;s in the fork and the package, and how I checked that the Markdown is right.</p>

<h2 class="relative group">Why I needed it
    <div id="why-i-needed-it" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-i-needed-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A marketplace and community app I build answers <code>Accept: text/markdown</code> with Markdown instead of HTML, for AI agents and LLM clients that would rather not wade through nav bars and footers. I covered the edge side of that in the <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >post on crawlers and the cache</a>. Pages that come from app data build their Markdown directly, because their HTML is an empty JavaScript shell. Everything else is static HTML (the marketing, legal and blog pages), and that gets converted.</p>
<p>The problem with converting it is the links. A static site is full of links like <code>/en/posts/...</code> and <code>../setup/</code>. They&rsquo;re fine in a browser, which knows the page they came from. An agent reading the Markdown has no such context, so it can&rsquo;t follow <code>/login</code>. It needs <code>https://example.com/login</code>.</p>
<p>The pure-Python <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> has a <code>baseurl</code> option for this, and that&rsquo;s what I used at first. But it&rsquo;s slow, and its Markdown has problems I&rsquo;ll get to below. rust-html2text was fast and well tested, but it had no way to resolve links.</p>

<h2 class="relative group">Asking upstream first
    <div id="asking-upstream-first" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#asking-upstream-first" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>On 29 August I opened <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">issue #287</a> with the use case and the behaviour I wanted: RFC 3986 resolution, with absolute URLs and <code>mailto:</code>, <code>data:</code> and <code>#fragment</code> links left alone. A few minutes later I opened <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">pull request #288</a>. It added a <code>Config::base_url(...)</code> option, and I said in the issue that it was a starting point and that I&rsquo;d understand if the maintainer wanted to take a different approach.</p>
<p>On 11 October the maintainer closed the PR:</p>
<blockquote><p>Thanks for the contribution, but I&rsquo;m unable to accept this as it appears to be LLM generated. (I have now added a brief policy to the README.md - it wasn&rsquo;t previously stated)</p>
</blockquote><p>and replied on the issue:</p>
<blockquote><p>Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can&rsquo;t accept, but thanks for the attempt.</p>
<p>A short HTML sample and expected output would definitely help get the feature implemented!</p>
</blockquote><p>The README now has this policy:</p>
<blockquote><p>At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.</p>
</blockquote><p>That was a correct read: I wrote that code with an AI coding assistant. I don&rsquo;t think the policy is unreasonable, either. The copyright status of LLM output isn&rsquo;t settled, and it&rsquo;s the maintainer who would carry the risk of merging it into an MIT crate that other projects depend on. It&rsquo;s their project, and the reply was polite. The feature request is still open.</p>
<p>I still needed the feature, though, and arguing with a maintainer over their own contribution policy isn&rsquo;t a good way to get it. So I kept the change on a fork and built the Python package on top of that, and both say clearly that they aren&rsquo;t upstream.</p>

<h2 class="relative group">What&rsquo;s in the fork
    <div id="whats-in-the-fork" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#whats-in-the-fork" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The fork is the <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> branch</a> of my rust-html2text fork. I&rsquo;ve kept it to three small commits so that it rebases cleanly onto upstream. The main one is the <code>Config::base_url</code> option from the PR: relative links are resolved per <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a>, and without a base URL the output is unchanged. Another stops the crate rebuilding its style rules on every call, which took a short snippet from 7.3 µs to 5.6 µs. That&rsquo;s nothing on a big page, but it adds up when you call it a few thousand times. The third just exposes what the Markdown converter below needs from the crate.</p>

<h2 class="relative group">The Python package
    <div id="the-python-package" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-python-package" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a> is built with PyO3 and maturin, and it has two functions:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2text</span><span class="p">,</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2text</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">80</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">decorate</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">link_footnotes</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">table_borders</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">wrap_links</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">max_wrap_width</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">raw</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></span></span></code></pre></div></div>
<p><code>html2text</code> is rust-html2text&rsquo;s plain-text renderer, which is built for terminals and the <code>text/plain</code> part of an email:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2text</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Please &lt;a href=&#34;https://jared.lynskey.co.nz/login&#34;&gt;sign in&lt;/a&gt; first.&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/page.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please [sign in][1] first.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[1]: https://example.com/login</span></span></code></pre></div></div>
<p>Some things about the bindings that may not be obvious:</p>
<ul>
<li><strong>Both functions take <code>str</code> or UTF-8 <code>bytes</code></strong>, so you can pass <code>response.content</code> without decoding it first. The input is borrowed, not copied.</li>
<li><strong>The GIL is released while converting.</strong> It&rsquo;s pure Rust from start to finish, so threads scale across cores. The module also says it doesn&rsquo;t need the GIL, so free-threaded Python 3.14t doesn&rsquo;t turn it back on when the module is imported.</li>
<li><strong>A panic comes back as <code>ValueError</code>.</strong> By default PyO3 raises a <code>PanicException</code>, which <code>except Exception</code> doesn&rsquo;t catch. A web worker that thinks it has handled conversion errors would crash on that, so the bindings catch it and re-raise it as a <code>ValueError</code>.</li>
<li><strong>There are 30 wheels:</strong> abi3 wheels for CPython 3.10 and later, plus free-threaded 3.14t wheels, across Linux (glibc and musl), macOS and Windows on every architecture maturin&rsquo;s CI can build. So nobody needs a Rust toolchain to install it.</li>
</ul>

<h2 class="relative group">Making it fast
    <div id="making-it-fast" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#making-it-fast" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Version 0.2.0 was 1.5 to 1.7 times faster than 0.1.0 on real pages, and twice as fast on short snippets, with byte-for-byte identical output. Most of that came from profile-guided optimisation: CI builds an instrumented wheel, runs it over a training set of documentation pages and RFCs, and uses the profile to build the real wheel. That&rsquo;s about 25% faster on pages outside the training set, on the platforms where CI can run the instrumented build natively. The rest came from switching to the mimalloc allocator, which copes far better with lots of small allocations, and from the style-rule change in the fork.</p>
<p>To check the speed-ups didn&rsquo;t change anything, I hashed the output for a corpus of real pages under every set of options, plus edge cases like empty input and very deep nesting. Each change had to leave every hash the same.</p>
<p>Here are the median times on an Apple Silicon Mac, converting to Markdown:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Encyclopaedia article (1 MB)</th>
					<th>News site front page (370 KB)</th>
					<th>Python docs page (245 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/markdownify/"  target="_blank" rel="noreferrer">markdownify</a></td>
					<td>184 ms</td>
					<td>28 ms</td>
					<td>97 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (pure Python)</td>
					<td>73 ms</td>
					<td>13 ms</td>
					<td>37 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html-to-markdown/"  target="_blank" rel="noreferrer">html-to-markdown</a></td>
					<td>44 ms</td>
					<td>5.6 ms</td>
					<td>15 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>14 ms</strong></td>
					<td><strong>1.7 ms</strong></td>
					<td><strong>4.5 ms</strong></td>
			</tr>
	</tbody>
</table>
<p>And converting to plain text:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Encyclopaedia article (780 KB)</th>
					<th>News site front page (370 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (pure Python)</td>
					<td>71.5 ms</td>
					<td>14.9 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text-rs/"  target="_blank" rel="noreferrer">html2text-rs</a></td>
					<td>15.2 ms</td>
					<td>2.5 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>10.9 ms</strong></td>
					<td><strong>1.6 ms</strong></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">Deeply nested HTML, and a patch to html5ever
    <div id="deeply-nested-html-and-a-patch-to-html5ever" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#deeply-nested-html-and-a-patch-to-html5ever" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Nobody writes 40,000 nested lists by hand. But if you convert pages you didn&rsquo;t write, sooner or later something hostile or broken will send you one. With the stock parser, 40,000 nested <code>&lt;ul&gt;&lt;li&gt;</code> took <strong>6 seconds</strong> to parse, and that&rsquo;s a worker stuck on a single request.</p>
<p>The cause was in <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>, the HTML5 parser rust-html2text uses. Most block-level start tags have to check whether there&rsquo;s an open <code>&lt;p&gt;</code> element &ldquo;in button scope&rdquo;, and html5ever answered that by walking the whole stack of open elements. The deeper the nesting, the longer each tag took, so the total time grew with the square of the depth.</p>
<p>The package ships html5ever 0.39.0 with one patch that keeps the answer up to date as elements are opened and closed, so the check no longer depends on depth. The same document now parses in <strong>0.14 seconds</strong>. To be sure the patch changed nothing else, I compared the DOMs from the stock and patched parsers on every html5lib tree-construction test, about 20 real pages and 300,000 random tag-soup documents, and they were all identical.</p>
<p>The Markdown converter has its own limits for the same reason. No input can overflow the thread&rsquo;s stack, blockquotes and lists stop indenting past 32 levels so the output can&rsquo;t grow quadratically, and a table whose expanded grid would pass 100,000 cells is written as its cells alone.</p>

<h2 class="relative group">html2markdown
    <div id="html2markdown" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html2markdown" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Version 0.3.0 added <code>html2markdown</code>, and most of the work in the package went into it. rust-html2text&rsquo;s renderer makes text that looks like Markdown, but it&rsquo;s wrapped for a terminal and isn&rsquo;t meant to be parsed. So <code>html2markdown</code> only borrows the parser and writes its own <a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>, plus GitHub-style pipe tables and <code>~~strikethrough~~</code>:</p>
<ul>
<li><code>#</code> headings, <code>-</code> bullets, <code>_emphasis_</code> and <code>**strong**</code>.</li>
<li>Inline links and images, made absolute when <code>base_url</code> is given. A link whose only content is an icon uses its <code>aria-label</code> or <code>title</code> as its text.</li>
<li>Fenced code blocks, tagged with the language when the page names one (<code>class=&quot;language-python&quot;</code>).</li>
<li>Pipe tables. Cells that span rows or columns become empty cells, a table without a header row gets an empty one, and tables used for page layout are written as paragraphs.</li>
<li>No line wrapping. Hidden content (<code>hidden</code>, <code>aria-hidden</code>, <code>display: none</code>), scripts, styles and form controls are left out.</li>
</ul>

<h3 class="relative group">Escaping only what needs it
    <div id="escaping-only-what-needs-it" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#escaping-only-what-needs-it" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Some HTML-to-Markdown converters escape every character that could be Markdown syntax. markdownify, for example, escapes every <code>_</code> and <code>*</code> by default. The output is correct, but it&rsquo;s full of <code>snake\_case</code> and <code>5 \* 3</code>, which is harder for people to read and a waste of tokens for a model. pyhtml2text escapes a character only where a CommonMark parser would otherwise read it as syntax:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Use &lt;code&gt;snake_case&lt;/code&gt;, 5 * 3, $5 and &#39;</span>
</span></span><span class="line"><span class="cl">              <span class="s1">&#39;&lt;a href=&#34;../setup.html&#34;&gt;setup&lt;/a&gt;&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">Use <span class="sb">`snake_case`</span>, 5 * 3, $5 and [<span class="nt">setup</span>](<span class="na">https://example.com/setup.html</span>)</span></span></code></pre></div></div>

<h3 class="relative group">Bold before a Korean particle
    <div id="bold-before-a-korean-particle" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#bold-before-a-korean-particle" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>This one came from my app&rsquo;s own pages. In Korean the grammatical particle goes straight after the noun with no space, so a bold term with brackets in it looks like this:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;&lt;</span><span class="nt">b</span><span class="p">&gt;</span>타이타닉(Titanic)<span class="p">&lt;/</span><span class="nt">b</span><span class="p">&gt;</span>의 감독<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>The obvious Markdown is <code>**타이타닉(Titanic)**의</code>, which is what markdownify writes, and it doesn&rsquo;t work. CommonMark&rsquo;s flanking rules don&rsquo;t let <code>**</code> close after punctuation unless a space or more punctuation follows it, and <code>의</code> is a letter, so the asterisks stay as literal text. The pure-Python html2text avoids this by adding a space:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic)** 의 감독</span></span></code></pre></div></div>
<p>That renders as bold, but it changes the Korean. The particle is now a separate word, which is a spelling mistake. pyhtml2text moves the punctuation outside the markers instead:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic**)의 감독</span></span></code></pre></div></div>
<p>The bracket is no longer bold, but the emphasis still parses and every character of the original is kept. The same applies to Japanese and Chinese, and to any language that doesn&rsquo;t put a space before what follows.</p>

<h3 class="relative group">Checking the output by reading it back
    <div id="checking-the-output-by-reading-it-back" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#checking-the-output-by-reading-it-back" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>I don&rsquo;t trust a converter because its unit tests pass, because the bugs in Markdown output are in combinations nobody thought to write a test for. So the output gets checked by reading it back.</p>
<p>While converting, each paragraph is parsed again as CommonMark to confirm the emphasis reads as intended. If it doesn&rsquo;t, the paragraph is written again with the other emphasis style (<code>*</code> instead of <code>_</code>, or the other way round), and if neither works the text, links and code are kept without the formatting, so no words are lost. The test suite does a round trip through a different Markdown parser and compares every visible word, link target and image source with the original HTML. Text eaten by syntax that wasn&rsquo;t escaped, a link that didn&rsquo;t parse or a stray <code>*</code> all show up as differences. It runs over real pages and tens of thousands of random documents, and shrinks any failure down to a minimal case.</p>

<h2 class="relative group">In production
    <div id="in-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#in-production" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>My app&rsquo;s Markdown negotiation switched from the pure-Python html2text to pyhtml2text the day 0.3.0 came out. On one of its guide pages the conversion went from 4.2 ms to 0.36 ms, about 12 times faster. The links are absolute, the tables are real pipe tables, and the Korean bold keeps its particles attached. The abi3 wheels already cover Python 3.14 on x86_64 and arm64, so the container image needed nothing new to install it.</p>

<h2 class="relative group">Credits, and where to report bugs
    <div id="credits-and-where-to-report-bugs" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#credits-and-where-to-report-bugs" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The plain-text rendering is rust-html2text, by Chris Emerson, under the MIT licence. pyhtml2text adds a fork of it, a patched copy of html5ever (MIT/Apache-2.0) and the Markdown converter. The package isn&rsquo;t affiliated with or endorsed by either project, so <strong>please report problems to <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2text&rsquo;s issue tracker</a></strong>, not upstream. Any bug in this code is mine to fix, and the upstream maintainers shouldn&rsquo;t get reports about code they declined to take.</p>
<p>If you&rsquo;re converting HTML in Python, especially for a model to read, give it a try:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<p>The source is on <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a>, and the changelog lists everything in 0.1.0 to 0.3.0.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
