Markdown to PDF Converter
Convert Markdown files to professional PDFs with full Chinese support, clickable bookmarks, and page numbers.
Features
- Full CJK + Latin + special character support (├── └── │ etc.)
- Auto-generated bookmarks from h1/h2/h3 headings with accurate page jumps
- Code blocks with background shading
- Tables, blockquotes, lists properly formatted
- Page numbers at bottom
- Auto font discovery (DroidSansFallbackFull, NotoSansCJK, WQY, etc.)
Workflow
1. Ensure dependencies
python3 -c "import fitz" 2>/dev/null || pip3 install pymupdf
2. Find a CJK font
The script auto-searches for fonts in these locations:
- User-specified
--fontpath ~/.fonts//usr/share/fonts/- Android SDK fonts (DroidSansFallbackFull.ttf)
If no font is found automatically, locate one:
find / -name "DroidSansFallbackFull.ttf" -o -name "NotoSansCJK*" -o -name "wqy-zenhei*" 2>/dev/null | head -5
3. Run the converter
python3 <skill-dir>/scripts/md2pdf.py input.md output.pdf
With explicit font:
python3 <skill-dir>/scripts/md2pdf.py input.md output.pdf --font /path/to/DroidSansFallbackFull.ttf
4. Verify output
After generating, verify the PDF:
import fitz
doc = fitz.open("output.pdf")
print(f"Pages: {len(doc)}")
print(f"Bookmarks: {len(doc.get_toc())}")
# Check first page text renders correctly
print(doc[0].get_text()[:300])
Supported Markdown Elements
| Element | Syntax | PDF Rendering |
|---|---|---|
| Headings | # ## ### |
Sized text + bookmark entry |
| Code blocks | ``` |
Gray background, monospace-style |
| Inline code | `code` |
Red text with pink background |
| Tables | | col | |
Bordered table with header shading |
| Blockquotes | > text |
Gray text with left border |
| Lists | - item / 1. item |
Bulleted / numbered |
| Bold | **text** |
Bold text |
| Links | [text](url) |
Text preserved, URL stripped |
| HR | --- |
Spacer |
Font Recommendation
DroidSansFallbackFull.ttf is the recommended font. It produces ~7.7MB PDFs and covers:
- Latin characters (A-Z, a-z, 0-9)
- CJK Chinese characters
- Box-drawing characters (├── └── │)
- All common symbols
This font is bundled in Android SDK at frameworks/base/data/fonts/DroidSansFallbackFull.ttf.
Bookmark accuracy
Bookmarks are located by finding each heading's text in the rendered pages, with three safeguards (added 2026-07-23 after real breakage in a CJK document):
- Fallback beyond
search_for()— PyMuPDF'ssearch_for()silently fails on some headings (observed with、()/and enclosed numerals like②④). It used to fall back to page 1, so those bookmarks jumped to the cover. Now a normalizedget_text()line scan catches them. - Monotonic search — each heading is searched from the previous heading's page onward, so a later heading can't match an earlier page that repeats the same words.
- HTML entities un-escaped — a heading like
路径 <version>is<version>in the intermediate HTML but renders as<version>; without un-escaping, the lookup could never match.
Verify after generating:
import fitz, unicodedata
def norm(s): return unicodedata.normalize("NFC", s).strip()
d = fitz.open("output.pdf")
lines = [[norm(l) for l in d[i].get_text().split("\n")] for i in range(len(d))]
for lvl, title, pg in d.get_toc():
key = norm(title)
real = next((i+1 for i, ls in enumerate(lines) if any(key == l or key in l for l in ls)), None)
if real != pg:
print(f"BAD: {title} -> bookmark p{pg}, actually p{real}")
Regression baseline: two CJK documents (8 pages / 19 bookmarks and 11 pages / 28 bookmarks) both report 0 mismatched bookmarks.
Edge Cases
- No CJK font available: Script exits with error message. User must provide
--font. - Very long documents: PyMuPDF handles multi-page layout automatically.
- Complex nested lists: Only single-level lists are supported; nested items render as flat.
- Images in markdown: Not supported; only text content is converted.