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
| Extra | Installs | Needed for |
|---|---|---|
[pdf] | PyMuPDF | PDF rendering — also powers figure extraction |
[office] | PyMuPDF | Office documents (routed through LibreOffice → PDF) |
[all] | everything | one-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
| Import | Why |
|---|---|
pathlib.Path | stdlib — walking the folder and building output paths |
structmd.StructMDPipeline | the orchestrator: converters + extractor + builder + cache, wired together |
structmd.config.StructMDConfig | typed configuration dataclass; every CLI/env option is a field on it |
Behavior you get for free
- Parallelism — all pages from all PDFs flow through one worker pool
(
ollama_max_workers), not one PDF at a time. - Resumability — each extracted page is cached under
~/.cache/structmd. Interrupted a 500-page run at page 200? Re-run it; only pages 201+ hit the model. - Failure isolation — one corrupt PDF logs an error and is skipped; the rest of the batch completes.
- Mixed inputs — pass
.docx,.pptx, images, anything supported; they ride the same pool.
The CLI equivalent:
$ structmd batch ./papers/ -o markdown/ --save-assetsPython 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
| Method | Returns | Description |
|---|---|---|
process(input_path, output_json=None, output_md=None, |
MarkdownDocument |
Full pipeline: convert → extract → build. Writes files when paths are given. |
extract_only(input_path, output_json=None, |
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, |
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
| Field | Type | Description |
|---|---|---|
title | str | None | Document title (first H1 candidate). |
content | str | The final Markdown. |
metadata | dict | Includes 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:
| Field | Type | Description |
|---|---|---|
type | ElementType | heading, paragraph, table, list_item, caption, image, code_block, blockquote, footnote, header, footer, page_number, horizontal_rule |
text | str | Element content (tables use table_data). |
bbox | BoundingBox | None | Pixel coordinates on the rendered page, top-left origin. |
page_number | int | True 1-indexed page number. |
table_data | list[list[str]] | None | Rows for table elements. |
heading_level | int | None | Depth for heading elements. |
confidence | float | VLM self-reported confidence. |
metadata | dict | Extensible — 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
| Class | Purpose |
|---|---|
BatchProcessor(extractor, max_workers=4, dpi=150, |
The async engine under the pipeline. await process_batch(paths, on_page_complete=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, |
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:
- 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 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:

Design notes
- Clip-rendering, not object extraction — figures in academic PDFs are usually vector graphics plus text labels; rendering the region captures exactly what a reader sees.
- Blank-crop guard — VLM boxes occasionally land on empty space; such crops are detected by pixel-uniformity and dropped instead of producing broken images.
- Degenerate-box guard — boxes are clamped to the page and slivers below 12 pt are skipped.
- PDF-only for now — Office/image inputs skip asset extraction gracefully.
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:
- Modify the file → everything for it invalidates automatically; no stale output, ever.
- Move or copy the file → the new path starts with a fresh entry on its first run.
- Page level: individual pages are stored as
{key}_page{N}.json. Re-running a 20-page paper after editing one paragraph re-pays for exactly one page. - 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 # 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
- An async worker pool drives conversions concurrently; progress renders as a live bar.
- A failure on one file — or one page — is logged and isolated; the batch continues.
- Outputs are written as
<stem>.mdinto the output directory. - Cached pages complete instantly, making re-runs cheap and resumable.
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
| Flag | Description |
|---|---|
-o, --output PATH | Output Markdown path (single mode) or directory (batch mode). |
--md PATH | Alias of -o. |
--json PATH | Save the intermediate Stage-1 extraction JSON. |
--from-json | Treat INPUT as extraction JSON; skip the VLM entirely. |
--model NAME | Ollama vision model tag. Overrides config/env. |
--url URL | Ollama API endpoint (default http://localhost:11434). |
--workers N | Batch concurrency (default 4). |
--dpi N | Page rendering resolution (default 150). |
--pages SPEC | Page selection, e.g. 1,3,5-10. Works in single and batch mode. |
--save-assets | Crop figure regions out of PDFs as PNGs and link them in the Markdown. |
--no-page-numbers | Suppress <!-- Page N --> markers. |
--no-merge | Do not merge paragraphs continued across pages. |
--force | Re-extract even if cached results exist (cache is refreshed). |
--no-cache | Disable the cache for this run. |
--config PATH | Explicit config file instead of the layered lookup. |
-v, --verbose | Debug 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:
- Built-in defaults
~/.config/structmd/config.yaml(global)./.structmd.yaml(per project)STRUCTMD_*environment variables- 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:
| Variable | Maps to |
|---|---|
STRUCTMD_OLLAMA_URL | Ollama endpoint |
STRUCTMD_OLLAMA_MODEL | Default vision model tag |
STRUCTMD_OLLAMA_TIMEOUT | Per-request timeout in seconds |
STRUCTMD_OLLAMA_MAX_WORKERS | Batch concurrency |
STRUCTMD_DPI | Rendering DPI |
STRUCTMD_CACHE_DIR | Cache directory override |
STRUCTMD_CACHE_ENABLED | true / false |
STRUCTMD_SAVE_ASSETS | Enable figure extraction |
STRUCTMD_VERBOSE | Debug 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
| Symptom | Fix |
|---|---|
ModelNotFoundError | Run ollama pull <tag>; for cloud models append :cloud. |
OllamaConnectionError | Is the daemon up? Try ollama list; check --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. |
A figure link shows image_placeholder | The VLM bbox missed or the crop was blank-guarded; re-extract that page with --pages N --force. |
| Re-run is slow although nothing changed | Make 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.