Featured image of post A Zero-Dependency Markdown-to-PDF Tool in One Python File

A Zero-Dependency Markdown-to-PDF Tool in One Python File

I just wanted to turn a Markdown file into a PDF.

That’s it. No login, no SaaS, no “drop your file here and we’ll email you the result.” I had a .md file on my laptop and I wanted a .pdf sitting next to it.

The internet’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.

1
python3 app.py

Your browser opens. You paste Markdown or drop a .md file, you get a live preview, you click Download PDF. That’s the whole thing. No pip install, no npm, works offline, and nothing leaves your machine.

The constraints are the design

I gave myself three rules:

  1. One command to run, using only what ships on macOS and Linux by default.
  2. Zero dependencies, no package manager involved at any point.
  3. Decent UX: paste or drop, with a live preview.

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.

So I don’t generate the PDF at all. Your browser already has a perfectly good PDF engine sitting behind Cmd/Ctrl + P, then “Save as PDF”. 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.

How it works

app.py is a standard-library HTTP server that serves one self-contained HTML page:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import http.server, socketserver, threading, webbrowser

HTML = r"""...one page: editor + preview + print styles..."""

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        body = HTML.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(body)

The page does the rest in the browser. A <textarea> on the left, a rendered preview on the right. Drop a file anywhere on the window and a FileReader loads it into the editor. The Markdown to HTML step runs client-side: online it uses marked 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.

The print CSS hides the editor and prints only the rendered document, with proper page margins and page-break-inside: avoid so code blocks and tables don’t get sliced across pages. “Download PDF” is just window.print(). The browser does the heavy lifting.

The bug that ate an afternoon

Here’s the part actually worth writing about.

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 “characters that will never show up in real text”, so I went with control characters like U+0000 and U+0001.

I tested the parser in Node. It passed. Code preserved, numbers intact, tables fine. Shipped it.

Then the report came back: “I can’t drop a file, and when I paste Markdown nothing happens.”

Nothing. Not a broken render, literally nothing. Paste dead, drag and drop dead. When every event listener dies at once, that isn’t a logic bug. The whole script is failing to load.

The cause was those markers getting written into the page as real bytes. Node’s JS engine happily eats a raw NUL inside a string. A browser’s HTML parser does not. It chokes on NUL and other C0 control bytes inside a <script>, the script never parses, and not one listener gets attached. The page just sits there looking fine and doing absolutely nothing.

Node tolerated the exact bytes the browser rejected. That’s why my test was green while the app was dead.

The fix was small. I swapped the control characters for Private Use Area characters (U+E000 to U+E003). Those are valid Unicode, safe inside a browser, and they won’t ever appear in someone’s Markdown. Then I stopped trusting the parser test and checked the actual bytes I was serving:

1
2
# scan the real response for C0 control bytes; you want zero matches
grep -aboP '[\x00-\x08\x0e-\x1f]' served.html

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 <script> tag in Chrome.

Get it

It’s MIT licensed and on GitHub:

👉 github.com/nikosdelis/md2pdf

1
2
3
git clone https://github.com/nikosdelis/md2pdf.git
cd md2pdf
python3 app.py

Sometimes the right amount of tooling is one file and the browser you already have open.

All rights reserved
Built with Hugo
Theme Stack designed by Jimmy