<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Local-First on ND Teknik</title><link>https://ndteknik.com/tags/local-first/</link><description>Recent content in Local-First on ND Teknik</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><copyright>Nikolaos Delis</copyright><lastBuildDate>Sun, 21 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://ndteknik.com/tags/local-first/index.xml" rel="self" type="application/rss+xml"/><item><title>A Zero-Dependency Markdown-to-PDF Tool in One Python File</title><link>https://ndteknik.com/posts/zero-dependency-markdown-to-pdf-tool/</link><pubDate>Sun, 21 Jun 2026 00:00:00 +0000</pubDate><guid>https://ndteknik.com/posts/zero-dependency-markdown-to-pdf-tool/</guid><description>&lt;img src="https://ndteknik.com/posts/zero-dependency-markdown-to-pdf-tool/featured.png" alt="Featured image of post A Zero-Dependency Markdown-to-PDF Tool in One Python File" /&gt;&lt;p&gt;&lt;strong&gt;I just wanted to turn a Markdown file into a PDF.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s it. No login, no SaaS, no &amp;ldquo;drop your file here and we&amp;rsquo;ll email you the result.&amp;rdquo; I had a &lt;code&gt;.md&lt;/code&gt; file on my laptop and I wanted a &lt;code&gt;.pdf&lt;/code&gt; sitting next to it.&lt;/p&gt;
&lt;p&gt;The internet&amp;rsquo;s answer to this is somehow either a 400 MB Electron app or a website that wants my email address first. So I built the smallest thing that does the job: a single Python file you run with one command.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 app.py
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Your browser opens. You paste Markdown or drop a &lt;code&gt;.md&lt;/code&gt; file, you get a live preview, you click &lt;strong&gt;Download PDF&lt;/strong&gt;. That&amp;rsquo;s the whole thing. No &lt;code&gt;pip install&lt;/code&gt;, no &lt;code&gt;npm&lt;/code&gt;, works offline, and nothing leaves your machine.&lt;/p&gt;
&lt;h2 id="the-constraints-are-the-design"&gt;The constraints are the design
&lt;/h2&gt;&lt;p&gt;I gave myself three rules:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;One command to run&lt;/strong&gt;, using only what ships on macOS and Linux by default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero dependencies&lt;/strong&gt;, no package manager involved at any point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decent UX&lt;/strong&gt;: paste or drop, with a live preview.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Rule 2 is the tricky one, because generating a PDF is usually exactly where you reach for a library. WeasyPrint, wkhtmltopdf, a headless Chrome. Every one of them is an install. Every one of them is a dependency.&lt;/p&gt;
&lt;p&gt;So I don&amp;rsquo;t generate the PDF at all. Your browser already has a perfectly good PDF engine sitting behind &lt;code&gt;Cmd/Ctrl + P&lt;/code&gt;, then &amp;ldquo;Save as PDF&amp;rdquo;. The tool just renders clean HTML and lets the browser do the PDF part. That one decision is the whole reason it has no dependencies.&lt;/p&gt;
&lt;h2 id="how-it-works"&gt;How it works
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;app.py&lt;/code&gt; is a standard-library HTTP server that serves one self-contained HTML page:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;http.server&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;socketserver&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;threading&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;webbrowser&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;HTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;...one page: editor + preview + print styles...&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HTML&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;utf-8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Content-Type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;text/html; charset=utf-8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The page does the rest in the browser. A &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; on the left, a rendered preview on the right. Drop a file anywhere on the window and a &lt;code&gt;FileReader&lt;/code&gt; loads it into the editor. The Markdown to HTML step runs client-side: online it uses &lt;a class="link" href="https://marked.js.org/" target="_blank" rel="noopener"
 &gt;marked&lt;/a&gt; from a CDN for the best fidelity, and offline it falls back to a small built-in parser that handles headings, bold, italic, code, lists, tables, blockquotes and links.&lt;/p&gt;
&lt;p&gt;The print CSS hides the editor and prints only the rendered document, with proper page margins and &lt;code&gt;page-break-inside: avoid&lt;/code&gt; so code blocks and tables don&amp;rsquo;t get sliced across pages. &amp;ldquo;Download PDF&amp;rdquo; is just &lt;code&gt;window.print()&lt;/code&gt;. The browser does the heavy lifting.&lt;/p&gt;
&lt;h2 id="the-bug-that-ate-an-afternoon"&gt;The bug that ate an afternoon
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part actually worth writing about.&lt;/p&gt;
&lt;p&gt;My built-in parser protects inline code and fenced code blocks by swapping them out for placeholder markers before the other regex passes run, then swapping them back at the end. Standard trick. For the markers I picked &amp;ldquo;characters that will never show up in real text&amp;rdquo;, so I went with control characters like &lt;code&gt;U+0000&lt;/code&gt; and &lt;code&gt;U+0001&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I tested the parser in Node. It passed. Code preserved, numbers intact, tables fine. Shipped it.&lt;/p&gt;
&lt;p&gt;Then the report came back: &amp;ldquo;I can&amp;rsquo;t drop a file, and when I paste Markdown nothing happens.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Nothing. Not a broken render, literally nothing. Paste dead, drag and drop dead. When every event listener dies at once, that isn&amp;rsquo;t a logic bug. The whole script is failing to load.&lt;/p&gt;
&lt;p&gt;The cause was those markers getting written into the page as real bytes. Node&amp;rsquo;s JS engine happily eats a raw &lt;code&gt;NUL&lt;/code&gt; inside a string. A browser&amp;rsquo;s HTML parser does not. It chokes on &lt;code&gt;NUL&lt;/code&gt; and other C0 control bytes inside a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, the script never parses, and not one listener gets attached. The page just sits there looking fine and doing absolutely nothing.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;Node tolerated the exact bytes the browser rejected. That&amp;rsquo;s why my test was green while the app was dead.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;The fix was small. I swapped the control characters for Private Use Area characters (&lt;code&gt;U+E000&lt;/code&gt; to &lt;code&gt;U+E003&lt;/code&gt;). Those are valid Unicode, safe inside a browser, and they won&amp;rsquo;t ever appear in someone&amp;rsquo;s Markdown. Then I stopped trusting the parser test and checked the actual bytes I was serving:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# scan the real response for C0 control bytes; you want zero matches&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -aboP &lt;span class="s1"&gt;&amp;#39;[\x00-\x08\x0e-\x1f]&amp;#39;&lt;/span&gt; served.html
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The lesson I keep having to relearn: test the thing you actually ship, in the thing that actually runs it. A green test in Node said nothing useful about a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag in Chrome.&lt;/p&gt;
&lt;h2 id="get-it"&gt;Get it
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s MIT licensed and on GitHub:&lt;/p&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a class="link" href="https://github.com/nikosdelis/md2pdf" target="_blank" rel="noopener"
 &gt;github.com/nikosdelis/md2pdf&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://github.com/nikosdelis/md2pdf.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; md2pdf
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 app.py
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Sometimes the right amount of tooling is one file and the browser you already have open.&lt;/p&gt;</description></item></channel></rss>