How to convert a scanned PDF into markdown with extracted images and preserved layout using marker-pdf. All facts below were empirically confirmed on macOS (Apple Silicon, CPU-only), Python 3.12 venv managed by uv.
Tool Choice
For the specific goal “scanned PDF → markdown, images extracted, original layout preserved”, marker-pdf is purpose-built — no custom pipeline needed.
Option
Verdict
marker-pdf
Chosen. Real headings, tables, extracted images, relative image links. Heavy (PyTorch).
docling
Lighter alternative, considered but not used.
ocrmypdf + tesseract + pdftotext -layout
Fastest, but produces crude markdown — no real headings or tables.
Install
pip is routed through uv on this machine
A bare pip install errors with No virtual environment found; run 'uv venv'.... Always create the venv with uv first.
uv pip install marker-pdf pulls ~2GB (mostly PyTorch). That is not the whole cost.
The first run additionally downloads ~1.34GB of model weights (model.safetensors) into the HuggingFace cache.
It is silent on stdout for a while before progress bars appear — do not assume it hung. Budget for this on any fresh machine.
Gotcha 2 — Output nests inside a stem-named subfolder
Output does not land directly in --output_dir. The actual layout is:
Images are written as siblings of the .md and referenced with relative links: . Move the .md without its siblings and every image breaks.
Why --force_ocr
Scanned PDFs often carry a junk/thin embedded text layer. Without --force_ocr, marker may trust that bad text layer instead of OCRing.
Diagnostics used to confirm a PDF is truly scanned:
pdftotext -f 1 -l 3 file.pdf - | wc -c # returned ~167 chars across 3 pages → text layer is garbagepdfimages -list file.pdf # pages made of many thin 1238x51 strips → scanner output
Performance
For Agents
2 pages ≈ 123s on CPU (Apple Silicon, no GPU).
A 564-page manual is impractical on CPU — extrapolates to ~10 hours.
TORCH_DEVICE=cudarequests GPU — it does not guarantee it. See the warning below.
Always smoke-test with --page_range 0-1 before committing expensive compute.
TORCH_DEVICE=cuda alone does NOT get you a GPU
If the installed torch build targets a newer CUDA than the host driver supports, torch.cuda.is_available() is False and marker silently falls back to CPU — no crash, no error, just hours instead of minutes. Always verify before a long run. Full procedure: Running it on a CUDA GPU box (telep-mainframe).
Process Lessons
bash -n is not verification
A syntax check on a wrapper script validates nothing about whether the wrapped tool’s CLI flags exist in the installed version. The cheap real verification is: confirm flags via --help, then run a small --page_range smoke run. Do this before spending GPU/compute time.
Surviving harness Bash tool timeouts
Long installs/runs launched via an agent Bash tool get killed at the tool timeout. Detach and poll the log instead — this also captures the true exit code:
Everything above was established on macOS/CPU. Running the same tool on the Debian 13 GPU box telep-mainframe (RTX 3080 12 GB, NVIDIA driver 550.163.01) needs a different install path and one non-obvious torch fix. Empirically confirmed 2026-07-18 on a 564-page scanned Suzuki Vitara service manual (g16b_manual_specific.pdf, 39 MB).
Install (no uv on this box)
Debian 13 is PEP 668 / externally-managed — a bare pip install refuses outright. Use a plain stdlib venv (this box does not use uv, unlike the macOS setup above):
pip install marker-pdf pulls torch 2.13.0+cu130 (CUDA 13 wheels). Driver 550.163.01 only supports CUDA 12.4, so torch cannot initialise CUDA:
torch.cuda.is_available() -> False
UserWarning: CUDA initialization: The NVIDIA driver on your system is too old (found version 12040)
It does not crash — it silently runs on CPU
marker keeps going on CPU. For 564 pages that is hours instead of ~22 minutes, with nothing in the log to indicate anything is wrong. Always verify CUDA is live before starting a long run.
You cannot just pin an old cu124 torch: surya-ocr (marker’s OCR engine) pins torch (>=2.7.0,<3.0.0).
Fix — install a CUDA 12.6 build, which runs fine on driver 550 via CUDA minor-version compatibility:
Trap — installing without uninstalling first is a silent no-op
Running pip install "torch>=2.7,<3" --index-url .../cu126withoutpip uninstall -y torch first reports Requirement already satisfied and changes nothing. The already-installed cu130 build satisfies the version constraint, and --index-url does not force a reinstall. You must uninstall first. (--force-reinstall also “works” but tends to fail, because the pytorch index does not carry the non-torch dependencies.)
Do NOT upgrade the system NVIDIA driver to get CUDA 13
Driver 550 is load-bearing on this box — Frigate’s GPU detection and the HDMI camera wall depend on it. A driver bump risks the NVR. Swapping torch inside the venv is isolated and reversible; touching the driver is not. See telep-mainframe.
Verify the GPU is actually engaged
python3 -c "import torch; print(torch.cuda.is_available(), torch.version.cuda)"# expect: True 12.6nvidia-smi --query-compute-apps=pid,used_memory --format=csv,noheader# the marker PID should appear, ~3.5GB VRAM
Model cache differs from the macOS path
On this install the weights land in ~/.cache/datalab/models/ (~3.3 GB) — not the HuggingFace cache described in Gotcha 1. The first run stalls several minutes here with no output in the log; that is the download, not a hang.
Real numbers (RTX 3080, GPU shared with Frigate)
Metric
Value
Input
564 pages, 39 MB scanned PDF, --force_ocr
Wall time
~22.5 min (1349 s)
Output markdown
794 KB / 15,970 lines
Extracted images
1,248 + a meta.json
marker VRAM
~3.5 GB (Frigate ~2.5 GB, 12 GB card)
Frigate impact
none — both fit comfortably
Reusing it
The venv persists at ~/ocr/venv on telep-mainframe; re-runs skip the 3.3 GB model download.
cd ~/ocr && . venv/bin/activate && TORCH_DEVICE=cuda ./ocr_to_markdown.sh <pdf>
Always run inside the activated venv
The wrapper ocr_to_markdown.sh falls back to pip install marker-pdf if marker_single is missing. On Debian, outside the venv, that fallback fails under PEP 668. Activating the venv first avoids the path entirely.