Installation
structmd requires Python 3.9+ and a reachable Ollama server (local daemon or Ollama Cloud).
Core package
$ pip install structmdFormat extras
Pick the extra matching your input formats:
$ pip install "structmd[pdf]" # PDF support via PyMuPDF $ pip install "structmd[office]" # Office documents via LibreOffice bridge $ pip install "structmd[all]" # everything at once
Images (.png, .jpg, .webp, .tiff, .bmp) work with the core install.
From source (development)
$ git clone https://github.com/umar052001/structmd.git $ cd structmd $ uv sync --extra all --extra dev $ uv run pytest
Ollama setup
Local models (fully private)
Install the Ollama daemon, then pull any vision-capable model:
$ ollama pull qwen2.5-vl:7b # strong general-purpose VLM $ ollama pull smolvlm # small & fast on CPUs
structmd talks to http://localhost:11434 by default. If your model tag is missing the
:latest suffix, structmd appends it automatically after checking /api/tags.
Ollama Cloud models
Cloud models run through the exact same local API — the daemon proxies requests to ollama.com, so no code changes are needed:
$ ollama signin # one-time account link $ ollama pull gemma4:cloud # registers the remote model locally $ structmd paper.pdf -o paper.md --model gemma4:cloud
Privacy note
Local models never send your documents anywhere. Cloud models route page images through Ollama's
infrastructure — choose based on your data sensitivity. Remote inference is also slower per request;
raise STRUCTMD_OLLAMA_TIMEOUT if you hit timeouts on large pages.
First conversion
$ structmd report.pdf -o report.md Extracting 12 pages ━━━━━━━━━━━━ 100% Building Markdown … done ✓ wrote report.md (38.2 KB)
The extraction JSON is written next to the output as report.extraction.json.
Keep it — it is your editable source of truth (see the two-stage workflow).
Python API
StructMDPipeline
The orchestrator facade. It wires converter → extractor → builder and manages the cache lifecycle.
from structmd import StructMDPipeline with StructMDPipeline( model="qwen2.5-vl:7b", # any Ollama vision tag base_url="http://localhost:11434", # default ) as pipeline: doc = pipeline.process("report.pdf", output_path="report.md") print(doc.markdown)
Stage separation
# Stage 1 only — extraction to JSON, no Markdown built extracted = pipeline.extract_only("report.pdf", pages="1,3,5-10") extracted.save_json("report.extraction.json") # Stage 2 only — rebuild Markdown from any extraction JSON md = pipeline.build_from_json("report.extraction.json", output_path="report.md")
Batch from Python
results = pipeline.process_batch( paths=["a.pdf", "b.docx", "scan.png"], output_dir="./markdown/", workers=4, # concurrent conversions ) for r in results: print(r.input_path, "->", r.output_path or r.error)
Inside a running event loop use await pipeline.process_batch_async(...) instead —
it shares the same worker pool without spawning threads over your loop.
CLI reference
| Flag | Description |
|---|---|
-o, --output PATH | Output Markdown path (single mode) or directory (batch mode). |
--model NAME | Ollama vision model tag. Overrides config/env. |
--base-url URL | Ollama API endpoint (default http://localhost:11434). |
--pages SPEC | Page selection, e.g. 1,3,5-10. Works in single and batch mode. |
--extract-only | Stop after stage 1; write the extraction JSON only. |
--from-json | Skip stage 1 entirely; rebuild Markdown from an existing JSON artifact. |
--force | Ignore cached results and re-run extraction. |
--no-cache | Disable the cache for this run. |
--workers N | Batch concurrency (default 4). |
--config PATH | Explicit config file instead of the layered lookup. |
-v, --verbose | Debug logging to stderr. |
Invocation patterns
$ structmd scan.png -o scan.md # image $ structmd deck.pptx -o deck.md # office (needs LibreOffice) $ structmd book.pdf -o book.md --pages 1,3,5-10 # page range $ structmd report.pdf --extract-only # JSON artifact only $ structmd report.pdf --from-json # deterministic rebuild $ structmd batch ./papers/ -o ./md/ --workers 6 # whole directory
Configuration
Settings resolve in layers — later sources win:
- Built-in defaults
~/.config/structmd/config.yaml(global)./.structmd.yaml(per project)STRUCTMD_*environment variables- CLI flags
Example .structmd.yaml
ollama: model: gemma4:cloud base_url: http://localhost:11434 timeout: 120 processing: dpi: 150 max_retries: 3 output: page_breaks: true image_alt_text: true cache: enabled: true dir: ~/.cache/structmd
Environment variables
| Variable | Maps to |
|---|---|
STRUCTMD_OLLAMA_MODEL | Default vision model tag |
STRUCTMD_OLLAMA_BASE_URL | Ollama endpoint |
STRUCTMD_OLLAMA_TIMEOUT | Per-request timeout in seconds |
STRUCTMD_CACHE_ENABLED | true / false |
STRUCTMD_CACHE_DIR | Cache directory override |
Caching
Every conversion result is stored under ~/.cache/structmd, keyed by a SHA-256 hash of the
input file's content, size and mtime — edit the PDF and the cache invalidates itself automatically.
- Document level: final extraction JSON per file (
{key[:2]}/{key}.json). - Page level: individual page extractions survive when other pages change.
- Atomic writes: results land via
os.replace, so crashes never corrupt entries.
$ structmd big.pdf -o big.md # slow first run $ structmd big.pdf -o big.md # instant — served from cache $ structmd big.pdf -o big.md --force # bypass and refresh the cache
Batch processing
Point the CLI at a directory (or pass many files) and structmd walks the tree for supported formats:
$ structmd batch ./contracts/ -o ./markdown/ --workers 4 --pages 1
- An async worker pool drives conversions concurrently; progress renders as a live bar.
- A failure on one file is logged and isolated — the batch continues.
- Outputs are written as
<stem>.mdinto the output directory. - Cached files complete instantly, making re-runs cheap and resumable.
The two-stage workflow
structmd deliberately forbids the VLM from writing Markdown directly. Instead:
- Stage 1 (model): each rendered page image is described as typed, positioned JSON elements.
- Artifact: the JSON is saved and cached — inspectable, diffable, hand-editable.
- Stage 2 (rules): a deterministic builder resolves columns, merges cross-page paragraphs, normalizes headings and emits Markdown. Identical JSON always yields identical bytes.
This gives you reproducibility for CI, a debugging surface when a model misreads a page, and the freedom to swap models without touching your output logic.
Extraction JSON format
{
"schema_version": 1,
"source": { "path": "report.pdf", "pages": 12 },
"pages": [
{
"page_number": 1,
"elements": [
{
"type": "heading", // heading|paragraph|list_item|table|
// image|code_block|blockquote|horizontal_rule
"content": "Introduction",
"level": 1, // heading depth
"bbox": [72.0, 95.4, 540.1, 118.2],
"confidence": 0.97
}
]
}
]
}Edit anything — fix a typo, promote a paragraph to a heading, reorder list items — then rebuild with
--from-json. The builder trusts the JSON completely.
Troubleshooting
| Symptom | Fix |
|---|---|
ModelNotFoundError | Run ollama pull <tag>; for cloud models append :cloud. |
OllamaConnectionError | Is the daemon up? Try ollama list; check --base-url. |
| Timeouts per page | Raise STRUCTMD_OLLAMA_TIMEOUT; large pages on CPU-bound models need more time. |
| Office conversion fails | Install LibreOffice (soffice must be on PATH). |
| Wrong reading order | Inspect the JSON bbox values; adjust then rebuild with --from-json. |
| Stale output after editing a file | Cache keys include content hash — if you truly need a re-run, use --force. |
Want to improve structmd? Read the contributing guide — the dev environment boots with one command and the entire test suite runs offline.