Installation

structmd requires Python 3.9+ and a reachable Ollama server (local daemon or Ollama Cloud).

Core package

$ pip install structmd

Format 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

FlagDescription
-o, --output PATHOutput Markdown path (single mode) or directory (batch mode).
--model NAMEOllama vision model tag. Overrides config/env.
--base-url URLOllama API endpoint (default http://localhost:11434).
--pages SPECPage selection, e.g. 1,3,5-10. Works in single and batch mode.
--extract-onlyStop after stage 1; write the extraction JSON only.
--from-jsonSkip stage 1 entirely; rebuild Markdown from an existing JSON artifact.
--forceIgnore cached results and re-run extraction.
--no-cacheDisable the cache for this run.
--workers NBatch concurrency (default 4).
--config PATHExplicit config file instead of the layered lookup.
-v, --verboseDebug 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:

  1. Built-in defaults
  2. ~/.config/structmd/config.yaml (global)
  3. ./.structmd.yaml (per project)
  4. STRUCTMD_* environment variables
  5. 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

VariableMaps to
STRUCTMD_OLLAMA_MODELDefault vision model tag
STRUCTMD_OLLAMA_BASE_URLOllama endpoint
STRUCTMD_OLLAMA_TIMEOUTPer-request timeout in seconds
STRUCTMD_CACHE_ENABLEDtrue / false
STRUCTMD_CACHE_DIRCache 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.

$ 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

The two-stage workflow

structmd deliberately forbids the VLM from writing Markdown directly. Instead:

  1. Stage 1 (model): each rendered page image is described as typed, positioned JSON elements.
  2. Artifact: the JSON is saved and cached — inspectable, diffable, hand-editable.
  3. 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

SymptomFix
ModelNotFoundErrorRun ollama pull <tag>; for cloud models append :cloud.
OllamaConnectionErrorIs the daemon up? Try ollama list; check --base-url.
Timeouts per pageRaise STRUCTMD_OLLAMA_TIMEOUT; large pages on CPU-bound models need more time.
Office conversion failsInstall LibreOffice (soffice must be on PATH).
Wrong reading orderInspect the JSON bbox values; adjust then rebuild with --from-json.
Stale output after editing a fileCache 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.