IO — Document Conversion
Scrivai's IO module converts PDF, DOC, and DOCX files to Markdown, and renders Markdown back to DOCX. The PDF conversion pipeline supports multiple OCR backends with concurrent processing, automatic fault tolerance, and batch operations.
Architecture Overview
┌─────────────────────┐
│ to_markdown(path) │ ← zero-config shorthand
└─────────┬───────────┘
│
┌─────────▼───────────┐
│ MarkdownConverter │ ← full-featured entry point
│ (ConverterConfig) │
└─────────┬───────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌──────▼──────┐
│ .doc/.docx │ │ .pdf │ │ .pdf │
│ markitdown │ │ API mode │ │ GPU mode │
│ (local) │ │ │ │ (MinerU) │
└───────────┘ └─────┬─────┘ └─────────────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼────┐ ┌────▼────┐ ┌─────▼────┐
│MonkeyOCR │ │GLM-OCR │ │ MinerU │
│(Docker) │ │(Cloud) │ │(HTTP API)│
└──────────┘ └─────────┘ └──────────┘
to_markdown() vs MarkdownConverter
to_markdown() |
MarkdownConverter |
|
|---|---|---|
| Use case | Quick one-off conversion | Production pipelines |
| Configuration | Zero-config (reads env vars) | Full ConverterConfig control |
| Multi-backend | Default backends from env | Custom backend priority, dispatch strategy |
| Batch processing | Single file only | convert_batch() with callbacks |
| Lifecycle | Creates & destroys converter per call | Reuse across multiple calls |
# Simple: zero-config, reads SCRIVAI_GLM_API_KEY / SCRIVAI_OCR_BASE_URL from env
from scrivai import to_markdown
md = to_markdown("report.pdf")
# Advanced: full control
from scrivai import MarkdownConverter
with MarkdownConverter(
mode="api",
monkey_endpoints=["http://gpu6:7866", "http://gpu7:7867"],
glm_api_keys=["key1", "key2"],
) as conv:
md = conv.convert("report.pdf")
Two Modes
API Mode (Default)
Uses remote OCR services (MonkeyOCR and/or GLM-OCR). PDFs are split into chunks and dispatched concurrently across backend groups.
with MarkdownConverter(mode="api", monkey_endpoints=["http://localhost:7866"]) as conv:
md = conv.convert("report.pdf")
GPU Mode
Uses a local MinerU instance via HTTP API. Suitable when you have GPU hardware available and want to avoid external API dependencies.
with MarkdownConverter(mode="gpu") as conv:
md = conv.convert("report.pdf")
MinerU auto-starts a mineru-router process if no URL is provided. First run downloads models (~2-3 GB).
OCR Backends
MonkeyOCR (Self-Hosted Docker)
A self-hosted OCR service running in Docker with GPU acceleration. Each instance binds to one GPU.
- Concurrency: 4 concurrent requests per instance (default)
- Input: PDF files uploaded via HTTP multipart
- Output: ZIP containing Markdown file
- Rate limit: Optional upload throttle via
upload_rate(bytes/sec)
with MarkdownConverter(
monkey_endpoints=["http://gpu6:7866", "http://gpu7:7867"],
upload_rate=512000, # 500 KB/s upload throttle
) as conv:
md = conv.convert("report.pdf")
GLM-OCR (ZhipuAI Cloud)
ZhipuAI's cloud OCR API. Requires an API key from open.bigmodel.cn.
- Concurrency: 3 concurrent requests per key (default)
- Input: Base64-encoded PDF (≤ 100 pages, ≤ 50 MB per chunk)
- Output: Markdown text (supports both legacy and ChatCompletion response formats)
- Chunking: Large PDFs are automatically split into chunks before sending
with MarkdownConverter(
glm_api_keys=["key1"],
) as conv:
md = conv.convert("report.pdf")
MinerU (Local GPU Pipeline)
Local OCR pipeline powered by MinerU. Uses intelligent auto-routing: text-based pages are extracted directly, scanned pages go through OCR. Includes table recognition, layout analysis, and formula detection.
- Concurrency: 2 files per group (default)
- Groups: Multiple MinerU instances via
mineru_urls - Auto-start: If no URL is provided, starts a local
mineru-routerautomatically
with MarkdownConverter(
mode="gpu",
mineru_urls=["http://gpu1:8000", "http://gpu2:8000"],
gpu_groups=2,
) as conv:
md = conv.convert("report.pdf")
ConverterConfig Reference
ConverterConfig is a dataclass that controls all converter behavior. All fields have sensible defaults.
API Mode Fields
| Field | Type | Default | Description |
|---|---|---|---|
mode |
"api" | "gpu" |
"api" |
Processing mode |
dispatch |
"overflow" | "fallback" |
"overflow" |
Multi-backend dispatch strategy |
api_backends |
list[str] |
["monkey", "glm"] |
Backend priority order |
concurrency_per_group |
dict[str, int] |
{"monkey": 4, "glm": 3} |
Per-backend per-group concurrency |
max_failures |
dict[str, int] |
{} |
Per-backend max failures before fallback (required for non-terminal backends in fallback mode) |
chunk_pages |
int |
50 |
Pages per PDF chunk |
overlap_pages |
int |
2 |
Overlapping pages between adjacent chunks |
chunk_strategy |
"file_first" | "interleave" |
"file_first" |
Batch chunk dispatch strategy |
GPU Mode Fields
| Field | Type | Default | Description |
|---|---|---|---|
gpu_backend |
str |
"mineru" |
GPU backend name |
gpu_concurrency_per_group |
int |
2 |
Concurrent files per GPU group |
gpu_groups |
int |
1 |
Number of GPU groups |
Common Fields
| Field | Type | Default | Description |
|---|---|---|---|
html_table |
"keep" | "to_markdown" |
"keep" |
HTML table post-processing |
timeout |
int |
300 |
Per-request HTTP timeout (seconds) |
retries |
int |
3 |
Max retry count per request |
retry_base_delay |
float |
10.0 |
First retry delay (seconds) |
retry_multiplier |
float |
2.0 |
Exponential backoff multiplier |
Failure Handling Fields
| Field | Type | Default | Description |
|---|---|---|---|
failure_threshold |
int |
2 |
Consecutive failures per group before cooldown |
failure_cooldown_max |
float |
300.0 |
Maximum cooldown duration (seconds) |
Credential Fields
| Field | Type | Default | Description |
|---|---|---|---|
glm_api_keys |
list[str] |
[] |
GLM API keys (one per group); falls back to SCRIVAI_GLM_API_KEY env |
monkey_endpoints |
list[str] |
[] |
MonkeyOCR endpoint URLs; falls back to SCRIVAI_OCR_BASE_URL env |
upload_rate |
int \| None |
None |
MonkeyOCR upload throttle (bytes/sec) |
mineru_urls |
list[str] |
[] |
MinerU instance URLs |
Multi-Group Concurrency
Each backend can have multiple groups — one group per credential (API key or endpoint). Groups are load-balanced with round-robin dispatch, and each group has independent concurrency semaphores and failure tracking.
# 2 MonkeyOCR instances + 3 GLM keys = 5 groups total
# MonkeyOCR: 4 concurrent/group × 2 groups = 8 slots
# GLM: 3 concurrent/group × 3 groups = 9 slots
# Total: 17 concurrent chunk requests
with MarkdownConverter(
monkey_endpoints=["http://gpu6:7866", "http://gpu7:7867"],
glm_api_keys=["key1", "key2", "key3"],
) as conv:
results = conv.convert_batch(pdf_files)
Dispatch Strategies
When multiple backends are configured, the dispatch strategy controls how chunks are assigned.
Overflow Strategy (Default)
Fills the primary backend first. When all primary groups are busy or in cooldown, spills to the next backend in api_backends order.
Chunk arrives → try monkey[0] → monkey[1] → glm[0] → glm[1] → wait → retry
Best for: maximizing throughput when primary backend is preferred but capacity is limited.
MarkdownConverter(
dispatch="overflow",
api_backends=["monkey", "glm"],
monkey_endpoints=["http://gpu6:7866"],
glm_api_keys=["key1"],
)
Fallback Strategy
Always tries the primary backend first. After max_failures consecutive failures on a backend, degrades to the next. Each chunk gets a fresh failure counter.
Chunk arrives → try monkey (up to max_failures) → degrade to glm → try glm
Best for: preferring one backend with automatic degradation on sustained errors.
MarkdownConverter(
dispatch="fallback",
api_backends=["monkey", "glm"],
max_failures={"monkey": 3}, # required for non-terminal backends
monkey_endpoints=["http://gpu6:7866"],
glm_api_keys=["key1"],
)
PDF Chunking & Merge
Large PDFs are split into overlapping chunks for parallel OCR processing.
How It Works
- Split: PDF is divided into chunks of
chunk_pagespages withoverlap_pagesoverlap between adjacent chunks - Dispatch: Chunks are dispatched concurrently to backend groups
- Merge: Results are reassembled with three-tier deduplication:
- Tier 1 — Difflib matching (~90%): Finds the longest matching block in the overlap region and cuts at the midpoint
- Tier 2 — Ratio estimation (~9%): When difflib fails, estimates the cut point by overlap ratio and snaps to the nearest paragraph boundary
- Tier 3 — Direct concatenation (<1%): Defensive fallback when no overlap is detected
Configuration
MarkdownConverter(
chunk_pages=50, # pages per chunk (default)
overlap_pages=2, # overlap between chunks (default)
)
Small PDFs (≤ chunk_pages) degrade to a single chunk with zero overhead.
Fault Tolerance
The converter implements three layers of fault tolerance:
Layer 1 — Per-Request Retry
Each backend call retries up to retries times with exponential backoff (retry_base_delay × retry_multiplier^n). Rate-limit responses (HTTP 429) trigger immediate cooldown instead of counting as a retry.
Layer 2 — Per-Group Cooldown
After failure_threshold consecutive failures on a single group, that group enters exponential backoff cooldown (up to failure_cooldown_max seconds). Other groups in the same backend remain available.
Layer 3 — Backend Dispatch
- Overflow mode: When all groups of a backend are in cooldown, chunks automatically spill to the next backend. If all backends are dead for longer than
failure_cooldown_max, anOcrBackendErroris raised. - Fallback mode: After
max_failureson a backend, the chunk degrades to the next backend in priority order.
Batch Processing
convert_batch() processes multiple files with two strategies:
file_first (Default)
Files are processed sequentially; chunks within each file are dispatched in parallel. Failed files don't block subsequent files.
with MarkdownConverter(monkey_endpoints=["http://gpu6:7866"]) as conv:
results = conv.convert_batch(["a.pdf", "b.pdf", "c.pdf"])
# results: {"a.pdf": "...", "b.pdf": "...", "c.pdf": "..."}
interleave
All files' chunks go into a single dispatch pool, maximizing backend utilization across files.
with MarkdownConverter(
chunk_strategy="interleave",
monkey_endpoints=["http://gpu6:7866"],
) as conv:
results = conv.convert_batch(pdf_files)
Completion Callback
Track per-file progress with on_complete:
def on_file_done(path: str, result: str | Exception) -> None:
if isinstance(result, Exception):
print(f"FAILED: {path} — {result}")
else:
print(f"OK: {path} ({len(result)} chars)")
with MarkdownConverter(monkey_endpoints=["http://gpu6:7866"]) as conv:
results = conv.convert_batch(pdf_files, on_complete=on_file_done)
When on_complete is provided, failed files are silently omitted from the result dict. Without it, any failure raises OcrBackendError.
Error Handling
The IO module exports two typed exceptions:
from scrivai.io import OcrBackendError, RateLimitError
try:
md = conv.convert("report.pdf")
except RateLimitError as e:
print(f"Rate limited by {e.backend} (HTTP {e.status_code})")
except OcrBackendError as e:
print(f"Backend error: {e.backend}[{e.group}] — HTTP {e.status_code}")
OcrBackendError— Base class for OCR backend errors. Attributes:status_code,backend,group.RateLimitError— HTTP 429 rate-limit error (subclass ofOcrBackendError).
HTML Table Post-Processing
OCR backends often return tables as HTML. The html_table config controls post-processing:
"keep"(default): Preserve raw HTML tables in the Markdown output"to_markdown": Convert simple HTML tables to Markdown format; tables withrowspan/colspanare kept as HTML (Markdown cannot represent merged cells)
MarkdownConverter(html_table="to_markdown") # requires markdownify package
Word File Conversion
Word files bypass the OCR pipeline entirely:
| Format | Pipeline |
|---|---|
.docx |
markitdown (local, instant) |
.doc |
LibreOffice headless → .docx → markitdown |
.pdf |
OCR backend (API or GPU mode) |
Environment Variables
For simple setups, configure backends via environment variables instead of ConverterConfig:
| Variable | Description |
|---|---|
SCRIVAI_GLM_API_KEY |
GLM-OCR API key (single key; for multiple keys use ConverterConfig.glm_api_keys) |
SCRIVAI_OCR_BASE_URL |
MonkeyOCR endpoint URL (single endpoint; for multiple use ConverterConfig.monkey_endpoints) |
SCRIVAI_MINERU_URL |
MinerU router URL (leave empty to auto-start local router) |
Environment variables are read at ConverterConfig.__post_init__() time. Explicit config fields always take precedence.
CLI
# Basic conversion (API mode, default)
scrivai-cli io convert --input report.pdf --output report.md
# GPU mode (local MinerU)
scrivai-cli io convert --input report.pdf --output report.md --mode gpu
The CLI uses default ConverterConfig with env-based credentials. For advanced configuration (multi-backend, dispatch strategy, batch), use the Python API.