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.

OptionVerdict
marker-pdfChosen. Real headings, tables, extracted images, relative image links. Heavy (PyTorch).
doclingLighter alternative, considered but not used.
ocrmypdf + tesseract + pdftotext -layoutFastest, 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 venv --python 3.12 markerenv
source markerenv/bin/activate
uv pip install marker-pdf

Usage

Confirmed against marker_single --help. The input path is a positional arg (FPATH), not a flag.

marker_single <INPUT.pdf> --output_dir <DIR> --force_ocr

Confirmed flags:

  • --output_format [markdown|json|html|chunks]
  • --page_range — e.g. 0-4 (0-indexed)
  • --force_ocr

Gotchas

Gotcha 1 — There are TWO separate large downloads

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:

<output_dir>/<pdf-stem>/<pdf-stem>.md
<output_dir>/<pdf-stem>/_page_0_Picture_2.jpeg
<output_dir>/<pdf-stem>/<pdf-stem>_meta.json

Images are written as siblings of the .md and referenced with relative links: ![](_page_0_Picture_2.jpeg). 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 garbage
pdfimages -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=cuda requests 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:

( marker_single in.pdf --output_dir out --force_ocr > run.log 2>&1; echo "EXIT=$?" >> run.log ) &

Then poll run.log across subsequent tool calls.

Running it on a CUDA GPU box (telep-mainframe)

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):

python3 -m venv ~/ocr/venv
. ~/ocr/venv/bin/activate
pip install marker-pdf

The CUDA driver mismatch

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:

pip uninstall -y torch
pip install "torch>=2.7,<3" --index-url https://download.pytorch.org/whl/cu126

Result: torch 2.13.0+cu126, cuda_available True, RTX 3080 detected.

Trap — installing without uninstalling first is a silent no-op

Running pip install "torch>=2.7,<3" --index-url .../cu126 without pip 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.6
 
nvidia-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)

MetricValue
Input564 pages, 39 MB scanned PDF, --force_ocr
Wall time~22.5 min (1349 s)
Output markdown794 KB / 15,970 lines
Extracted images1,248 + a meta.json
marker VRAM~3.5 GB (Frigate ~2.5 GB, 12 GB card)
Frigate impactnone — 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.