Installation

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

Core package

$ pip install "structmd[pdf]"   # uv: uv add "structmd[pdf]"

Format extras

ExtraInstallsNeeded for
[pdf]PyMuPDFPDF rendering — also powers figure extraction
[office]PyMuPDFOffice documents (routed through LibreOffice → PDF)
[all]everythingone-stop install

Images (.png, .jpg, .webp, .tiff, .bmp) work with the core install. Office conversion additionally needs LibreOffice (soffice) on your PATH.

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-vl:2b       # ~1.6 GB, good default
$ 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. Cloud inference is also typically faster than CPU-bound local models; raise STRUCTMD_OLLAMA_TIMEOUT if you hit timeouts on large pages.

First conversion

$ structmd report.pdf -o report.md --json report.json

Extracting 12 pages ━━━━━━━━━━━━ 100%
 wrote report.md

Add --json when you want the Stage-1 artifact on disk. It is optional — but keep it: it is your editable source of truth (see the two-stage workflow).

Convert a folder of PDFs

The most common job: point structmd at a directory and get one .md per document. Here is a complete, runnable script:

from pathlib import Path

from structmd import StructMDPipeline
from structmd.config import StructMDConfig

# 1. Configure once — these settings apply to every document.
config = StructMDConfig(
    ollama_model="gemma4:cloud",   # or a local tag, e.g. "qwen2-vl:2b"
    ollama_max_workers=3,          # pages processed concurrently
    save_assets=True,              # crop figures out as PNGs
)

# 2. Collect inputs. rglob walks subfolders; glob is top-level only.
pdfs = sorted(Path("papers").rglob("*.pdf"))
print(f"Found {len(pdfs)} PDFs")

# 3. Run. All pages of all documents share one async worker pool,
#    and finished pages are cached individually.
with StructMDPipeline(config) as pipeline:
    documents = pipeline.process_batch(
        [str(p) for p in pdfs],
        output_dir="markdown",     # anchors figure assets at markdown/figures/
    )

# 4. Write the Markdown files.
for doc in documents:
    stem = Path(doc.metadata["source_path"]).stem
    target = Path("markdown") / f"{stem}.md"
    doc.save(str(target))
    print(f"{target}  ({doc.metadata['page_count']} pages)")

What each import gives you

ImportWhy
pathlib.Pathstdlib — walking the folder and building output paths
structmd.StructMDPipelinethe orchestrator: converters + extractor + builder + cache, wired together
structmd.config.StructMDConfigtyped configuration dataclass; every CLI/env option is a field on it

Behavior you get for free

The CLI equivalent:

$ structmd batch ./papers/ -o markdown/ --save-assets

Python API

Everything in this section is importable from the package root: from structmd import StructMDPipeline, StructMDConfig, …

StructMDPipeline

The high-level facade. It wires converter → extractor → builder, manages the cache lifecycle, and works as a context manager so HTTP resources are released on exit.

from structmd import StructMDPipeline, StructMDConfig

config = StructMDConfig(
    ollama_model="qwen2-vl:2b",          # any Ollama vision tag
    ollama_url="http://localhost:11434",  # default
)

with StructMDPipeline(config) as pipeline:
    result = pipeline.process(
        "report.pdf",
        output_json="report.json",   # optional: keep Stage 1 output
        output_md="report.md",       # optional: write final Markdown
    )
    print(result.title)                      # from the first heading
    print(result.content[:200])              # the Markdown itself
    print(result.metadata["page_count"])     # 12

Pipeline methods

MethodReturnsDescription
process(input_path, output_json=None, output_md=None,
pages=None, force=False)
MarkdownDocument Full pipeline: convert → extract → build. Writes files when paths are given.
extract_only(input_path, output_json=None,
pages=None, force=False)
ExtractedDocument Stage 1 only. Results cached per page.
build_from_json(json_path, output_md=None) MarkdownDocument Stage 2 only. No VLM call, no source file needed.
process_batch(paths, pages=None,
force=False, output_dir=None)
list[MarkdownDocument] Synchronous batch over a shared worker pool. Safe in scripts and Jupyter.
process_batch_async(...) same Async variant for code already running inside an event loop.

pages takes a list of 1-indexed page numbers — e.g. [1, 3, 5, 6, 7]. Selected pages keep their true numbers throughout extraction, caching, and output markers.

MarkdownDocument

FieldTypeDescription
titlestr | NoneDocument title (first H1 candidate).
contentstrThe final Markdown.
metadatadictIncludes source_path, page_count, model, dpi.

.save(path) writes the file (prepends # title when appropriate) and returns the Path.

The extraction data model

ExtractedDocument holds pages: list[ExtractedPage]; each page holds elements: list[DocumentElement]. Every element carries:

FieldTypeDescription
typeElementTypeheading, paragraph, table, list_item, caption, image, code_block, blockquote, footnote, header, footer, page_number, horizontal_rule
textstrElement content (tables use table_data).
bboxBoundingBox | NonePixel coordinates on the rendered page, top-left origin.
page_numberintTrue 1-indexed page number.
table_datalist[list[str]] | NoneRows for table elements.
heading_levelint | NoneDepth for heading elements.
confidencefloatVLM self-reported confidence.
metadatadictExtensible — figure assets record asset_path here.

All three models serialize cleanly: .to_dict() / .from_dict() / .to_json() / .from_json(), plus ExtractedDocument.save_json(path).

Lower-level components

ClassPurpose
BatchProcessor(extractor, max_workers=4, dpi=150,
converters=None, cache=None, force=False)
The async engine under the pipeline. await process_batch(paths, on_page_complete=None,
on_doc_complete=None, pages=None)
returns list[ExtractedDocument]. Callbacks receive (doc_id, document) / (doc_id, page_number, page).
AssetExtractor(dpi=200) Figure cropping — see Figure extraction.
attach_assets(source_path, document,
markdown_output_path, dpi=200, dirname="figures")
Convenience wrapper that crops figures beside a Markdown target and annotates elements in place.
CacheManager(cache_dir="~/.cache/structmd") Content-addressed JSON cache with document-level (load/save) and page-level (load_page/save_page) APIs, atomic writes, and explicit invalidate(file_path).
OllamaExtractor(config) Stage 1 standalone: extract_page(image, page_number), extract_document(images), async twins included.
MarkdownBuilder(config=None) Stage 2 standalone: build(extracted) -> MarkdownDocument. Pure functions only.

Exceptions

All derive from structmd.core.StructMDError: OllamaConnectionError, ModelNotFoundError, ConversionError, CacheError.

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 pays off in practice — extract once, iterate forever:

from structmd import StructMDPipeline

with StructMDPipeline() as pipeline:
    # Stage 1 only: the VLM runs here (slow, cached afterwards)
    doc = pipeline.extract_only("report.pdf", output_json="report.json")

    # ... inspect / hand-edit report.json ...
    # e.g. fix a heading level, correct a table cell, drop a stray footer.

    # Stage 2 only: deterministic rebuild (instant, no VLM)
    md = pipeline.build_from_json("report.json", output_md="report.md")

Or from the shell:

$ structmd report.pdf --json report.json -o report.md   # full run
$ vim report.json                                       # fix the JSON
$ structmd --from-json report.json -o report.md         # instant rebuild

Figure extraction

Set save_assets=True (or pass --save-assets) and every region the VLM flagged as an image is clip-rendered from the source PDF at assets_dpi (default 200) into <output_dir>/figures/. The Markdown links the real files:

![Diagram of the Vision Transformer architecture](figures/2010.11929_p03_1.png)

Design notes

Standalone use

Attaching assets to an extraction you already have (e.g. loaded from cached JSON — no VLM needed):

from structmd import attach_assets

written = attach_assets("paper.pdf", extracted_document, "output/report.md", dpi=200)
# -> writes output/figures/*.png, returns the relative paths,
#    and annotates each image element with metadata["asset_path"]

Caching & resume

Every extraction result is stored under ~/.cache/structmd, keyed by a SHA-256 hash of the input file's absolute path, mtime and size:

$ structmd big.pdf -o big.md            # slow first run
$ structmd big.pdf -o big.md            # seconds — served from cache
$ structmd big.pdf -o big.md --force    # bypass reads, refresh entries
$ structmd big.pdf -o big.md --no-cache # ignore the cache entirely

Measured impact Our five-paper arXiv benchmark (77 pages, gemma4:cloud, 3 workers): 12m37s cold → 3.0s warm.

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

From Python

import asyncio
from pathlib import Path
from structmd import StructMDPipeline

async def main():
    with StructMDPipeline() as pipeline:
        docs = await pipeline.process_batch_async(
            [str(p) for p in Path("papers").glob("*.pdf")]
        )
        for d in docs:
            print(d.metadata["source_path"], d.metadata["page_count"], "pages")

asyncio.run(main())

pipeline.process_batch(...) is the synchronous twin — callable from plain scripts; inside Jupyter it detects the running event loop and delegates to a helper thread automatically.

Progress callbacks

Need visibility? Drop to BatchProcessor:

import asyncio
from structmd import BatchProcessor, OllamaExtractor
from structmd.config import StructMDConfig

config = StructMDConfig(ollama_model="gemma4:cloud")

def on_doc(doc_id, document):
    print(f"done: {document.source_path} ({document.page_count} pages)")

async def main():
    processor = BatchProcessor(
        OllamaExtractor(config),
        max_workers=config.ollama_max_workers,
    )
    documents = await processor.process_batch(
        ["a.pdf", "b.pdf"],
        on_doc_complete=on_doc,   # also: on_page_complete(doc_id, n, page)
    )

asyncio.run(main())

A tqdm progress bar renders out of the box.

CLI reference

FlagDescription
-o, --output PATHOutput Markdown path (single mode) or directory (batch mode).
--md PATHAlias of -o.
--json PATHSave the intermediate Stage-1 extraction JSON.
--from-jsonTreat INPUT as extraction JSON; skip the VLM entirely.
--model NAMEOllama vision model tag. Overrides config/env.
--url URLOllama API endpoint (default http://localhost:11434).
--workers NBatch concurrency (default 4).
--dpi NPage rendering resolution (default 150).
--pages SPECPage selection, e.g. 1,3,5-10. Works in single and batch mode.
--save-assetsCrop figure regions out of PDFs as PNGs and link them in the Markdown.
--no-page-numbersSuppress <!-- Page N --> markers.
--no-mergeDo not merge paragraphs continued across pages.
--forceRe-extract even if cached results exist (cache is refreshed).
--no-cacheDisable the cache for this run.
--config PATHExplicit config file instead of the layered lookup.
-v, --verboseDebug logging, including raw VLM I/O.

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 --json r.json -o r.md        # keep the JSON artifact
$ structmd --from-json r.json -o r.md              # deterministic rebuild
$ structmd batch ./papers/ -o ./md/ --workers 6   # whole directory
$ structmd batch ./papers/ -o ./md/ --save-assets  # …with figure PNGs

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:
  url: "http://localhost:11434"
  model: "qwen2-vl:2b"
  timeout: 120            # seconds per chat call
  max_workers: 4          # concurrent page extractions

processing:
  dpi: 150
  detect_columns: true            # heuristic multi-column reading order
  merge_continued_paragraphs: true
  normalize_headings: true        # remap [1,3,3] -> [1,2,2]

output:
  include_page_numbers: true
  page_number_format: "\n<!-- Page {page} -->\n"
  table_caption_position: "before"  # or "after"

cache:
  dir: "~/.cache/structmd"

# flat keys work too:
# cache_enabled: true
# save_assets: true
# assets_dirname: "figures"
# assets_dpi: 200

Environment variables

Every config field maps to a STRUCTMD_* variable (upper-cased field name). The common ones:

VariableMaps to
STRUCTMD_OLLAMA_URLOllama endpoint
STRUCTMD_OLLAMA_MODELDefault vision model tag
STRUCTMD_OLLAMA_TIMEOUTPer-request timeout in seconds
STRUCTMD_OLLAMA_MAX_WORKERSBatch concurrency
STRUCTMD_DPIRendering DPI
STRUCTMD_CACHE_DIRCache directory override
STRUCTMD_CACHE_ENABLEDtrue / false
STRUCTMD_SAVE_ASSETSEnable figure extraction
STRUCTMD_VERBOSEDebug logging

Extraction JSON format

The Stage-1 artifact is a faithful serialization of the data model — this exact shape is what --json writes, what the cache stores, and what --from-json accepts:

{
  "document_id": "612aefcb65144afb8ad05d6317d945e",
  "source_path": "papers/report.pdf",
  "page_count": 1,
  "pages": [
    {
      "page_number": 1,
      "width": 1275.0,                // rendered pixels at extraction DPI
      "height": 1650.0,
      "elements": [
        {
          "id": "elem_6b35b70785e6",
          "type": "heading",           // see ElementType list above
          "bbox": {"x1": 70.0, "y1": 80.0, "x2": 400.0, "y2": 110.0},
          "text": "Introduction",
          "page_number": 1,
          "confidence": 1.0,
          "metadata": {},
          "heading_level": 1,
          "list_type": null,
          "indent_level": 0,
          "continues_on_next_page": false,
          "table_data": null
        }
      ]
    }
  ],
  "metadata": {"extractor": "ollama", "model": "gemma4:cloud", "dpi": 150}
}

Edit anything — fix a typo, promote a paragraph to a heading, correct a table cell — 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 --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.
A figure link shows image_placeholderThe VLM bbox missed or the crop was blank-guarded; re-extract that page with --pages N --force.
Re-run is slow although nothing changedMake sure caching isn't disabled (--no-cache / cache_enabled: false).

Want to improve structmd? Read the contributing guide — the dev environment boots with one command and the entire test suite runs offline.