pdf-to-markdown
Convert PDF files to markdown using tested CLI tools. Use when the user asks to convert a PDF to markdown, extract text from a PDF, or asks about pymupdf4llm, marker, or docling. Covers which tool to
SKILL.mdskills/pdf-to-markdown/SKILL.md
GitHubpdf-to-markdown
Three tools tested on a real 73-page book PDF (prose + screenshots + watermarks). Pick by need, default to marker for quality, pymupdf4llm for speed.
| Tool | Time (73p) | Quality verdict |
|---|---|---|
| marker | ~4 min | Best. Recovers hyperlinks, extracts images as files, dedupes text |
| pymupdf4llm | ~1 min | Fast, readable prose. But duplicates sentences and garbles screenshots inline |
| docling | ~6.5 min | Decent prose and headings, but bloats output with base64 images and zero links |
marker (best quality)
Needs llama-server for text recognition. On macOS install first:
brew install llama.cpp
Convert:
uvx --from marker-pdf marker_single <input.pdf> --output_dir /tmp/marker-out --output_format markdown
- Output lands in
<output_dir>/<pdf basename>/<pdf basename>.md - Images are extracted as
.jpegfiles next to the markdown, referenced by relative path. Keep the folder together when moving output - Recovers inline hyperlinks from the PDF. No duplicated sentences
pymupdf4llm (fastest)
With image extraction (images saved as PNG files, referenced by relative path):
uvx --from pymupdf4llm python - <<'EOF'
import pathlib, os, re, pymupdf4llm
os.chdir('/path/to/destination') # so image refs come out relative
for pdf in sorted(pathlib.Path('.').glob('*.pdf')):
# slug: path with spaces/parens breaks pymupdf4llm's image saving (see gotcha below)
slug = re.sub(r'[^a-zA-Z0-9]+', '-', pdf.stem).strip('-').lower()
md = pymupdf4llm.to_markdown(pdf.name, write_images=True,
image_path=f'{slug}-images', image_format='png',
show_progress=False)
pdf.with_suffix('.md').write_text(md, encoding='utf-8')
EOF
Without images, drop write_images/image_path/image_format.
Useful flags (verified against the source):
| Flag | Default | Use it when |
|---|---|---|
pages |
all pages | Convert a page range. Int or list of ints, 0-indexed |
embed_images=True |
False | Inline images as base64 instead of saving files. Mutually exclusive with write_images |
image_dpi |
150 | Higher (e.g. 300) for sharper screenshot files agents can read |
ocr_dpi |
150 | Higher for better OCR of small text |
use_ocr |
OCRMode.SELECT_KEEP_OLD |
NEVER skips OCR entirely (fast); FORCE_DROP_OLD OCRs every page |
force_text=True |
False | Also extract text that sits inside picture regions |
render_html_tables=True |
False | Render tables as HTML instead of markdown pipes, better for complex/scrambled tables |
edge_threshold |
None | Layout detection sensitivity, lower values split paragraphs more aggressively |
ocr_language |
"eng" |
Pass other languages for non-English PDFs |
ocr_function |
auto | Plug in a custom OCR callable (e.g. your own Tesseract wrapper) |
OCR needs an engine; on this machine Tesseract is available and gets picked automatically.
Known output issues: see the issue taxonomy below. pymupdf4llm hits nearly all of them.
Path bug (crashes with FzErrorSystem: cannot open file): pymupdf4llm sanitizes image paths for the markdown reference (spaces → _, parens → -) and uses that sanitized string as the actual save path, so any space, paren, or bracket in the PDF name or image_path breaks saving. Always os.chdir into the output folder, pass the PDF name relative, and slugify the image_path to alphanumerics and dashes.
docling
uvx docling convert <input.pdf> --to md --output /tmp/docling-out
- By default embeds every image as base64 inline, output can reach tens of MB. Strip with a regex over
!\[Image\]\(data:image[^)]+\)or use image export options before relying on the file - Does not recover hyperlinks
- Clean table of contents tables, decent heading detection
Issue taxonomy: what these CLIs get wrong, and how to fix
All verified on 5 real book PDFs (528 pages). Run the bundled cleanup script for the mechanical fixes:
# 1. dump the PDF text layer (ground truth for matching)
uvx --from pymupdf python -c "
import pymupdf, pathlib, sys
doc = pymupdf.open(sys.argv[1])
pathlib.Path(sys.argv[2]).write_text('\n'.join(p.get_text() for p in doc))
" input.pdf /tmp/pdf-text.txt
# 2. clean the markdown
bun ~/.config/opencode/skills/pdf-to-markdown/clean.ts --md output.md --pdf-text /tmp/pdf-text.txt \
--extra-noise 'your watermark or footer regex'
| Issue | Which tools | Fix |
|---|---|---|
| Duplicated sentences/paragraphs (overlapping text layers) | pymupdf4llm | Dedupe by normalized sentence, but only remove copies that appear once in the PDF text layer, so the author's real repetition survives. dedupeSentences in clean.ts |
| Garbled OCR of screenshots (token soup inline) | pymupdf4llm always, docling partially, marker rarely | Delete the OCR text, keep the image file (agents read images with vision). removePictureBlocks in clean.ts |
OCR garbage prefixes glued to real text (Brp3 Be Bn2 Adam has replied...) |
pymupdf4llm | Find first 3-word n-gram that matches the PDF text layer, drop everything before it. garbageFilter in clean.ts |
| Headings fused into mega-paragraphs | pymupdf4llm, docling | Extract titles from the book's TOC table, split fused paragraphs at each title (recurse: one paragraph can hold several), insert ## title. restoreHeadings in clean.ts |
Bogus headings (bold body sentences promoted to #) |
pymupdf4llm, marker | Demote by rules: ends with ,/./;, italic-wrapped, or level 4+ with >8 words followed by lowercase text or another heading. Sample every demotion and eyeball before accepting. demoteBogusHeadings in clean.ts |
| Boilerplate noise (watermarks, footers, page numbers as lines) | all | Regex strip, book-specific. Pass watermarks/footers via --extra-noise |
| Screenshots with no image ref at all | pymupdf4llm | Render the full PDF page to PNG with pymupdf (page.get_pixmap(dpi=150)), insert the ref at the right anchor by hand |
Split headings (title breaks across pages: # Chapter 9... + # Agency) |
pymupdf4llm | Merge consecutive # lines when the first matches a chapter pattern and the second is short |
| Restored heading duplicates an existing heading nearby | cleanup scripts themselves | Drop the duplicate if a same-text heading exists within a few lines above. dedupeHeadings in clean.ts |
| Content truncated mid-paragraph (fused and lost in the PDF's own text layer) | any, from source PDF | Unfixable automatically. Report honestly; the content only exists in the PDF itself |
| PDF's own embedded OCR layer is garbage (not the converter's fault) | scanned PDFs | The text layer pollutes both the conversion and any matching. Validate matchers on known-good prose before trusting pass/fail counts |
Workflow rules
- Run with
uvxso tools stay isolated, never global pip installs - First run downloads ML models (marker ~2 GB). Use generous timeouts, 10+ min
- After converting, verify image refs resolve: extract
!\[...\](...)paths from the markdown and check each file exists. Report count of refs and missing files - After converting, read a sample of the output (start, middle, end) and report quality honestly: duplicated sentences, garbled OCR, swallowed headings
- Check
file <output>.mdcontent exists and is not empty before telling the user it worked