from __future__ import annotations
import base64
import functools
import io
import json
import os
import tempfile
import threading
import time
import traceback
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
import torch
from flask import Flask, jsonify, request, Response
import PIL.Image
from marker.config.parser import ConfigParser
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
from marker.output import text_from_rendered
from marker.providers.registry import load_extensions
from marker.settings import settings as marker_settings
import request_store
import deepseek_ocr as dsocr
# ── Env configuration ────────────────────────────────────────────────
API_KEY = os.environ.get("API_KEY", "")
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11435")
DEESEEK_OCR_MODEL = os.environ.get("DEESEEK_OCR_MODEL", "deepseek-ocr")
OCR_BACKEND = os.environ.get("OCR_BACKEND", "marker") # "marker" | "deepseek"
AMD_COMPUTE = os.environ.get("AMD_COMPUTE", "false").lower() in ("true", "1", "yes")
TORCH_DEVICE = os.environ.get("TORCH_DEVICE", "")
MODEL_DTYPE = os.environ.get("MODEL_DTYPE", "float32")
DEFAULT_LLM_SERVICE = os.environ.get("LLM_SERVICE", "marker.services.ollama.OllamaService")
DEFAULT_USE_LLM = os.environ.get("USE_LLM", "false").lower() in ("true", "1", "yes")
DEFAULT_OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "")
DEFAULT_OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
DEFAULT_OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "")
SUPPORTED_EXTENSIONS = set()
for provider_type in ("image", "pdf", "epub", "doc", "xls", "ppt"):
SUPPORTED_EXTENSIONS.update(load_extensions(provider_type))
SUPPORTED_DISPLAY = sorted({ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS})
_marker_dict: Optional[Dict[str, Any]] = None
_lock = threading.Lock()
BASE_URL = os.environ.get("SELF_URL", "http://localhost:8001")
def _configure_env():
if AMD_COMPUTE and not TORCH_DEVICE:
os.environ["TORCH_DEVICE"] = "cuda"
os.environ["TORCH_DEVICE_MODEL"] = "cuda"
if MODEL_DTYPE == "bfloat16":
os.environ["MODEL_DTYPE"] = "bfloat16"
if torch_device_override := os.environ.get("TORCH_DEVICE"):
os.environ["TORCH_DEVICE"] = torch_device_override
os.environ["TORCH_DEVICE_MODEL"] = torch_device_override
def get_model_dict() -> Dict[str, Any]:
global _marker_dict
if _marker_dict is None:
with _lock:
if _marker_dict is None:
_marker_dict = create_model_dict()
return _marker_dict
# ── Auth decorator ───────────────────────────────────────────────────
def require_api_key(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
if API_KEY:
header_key = request.headers.get("X-API-Key", "")
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
header_key = header_key or auth_header[7:]
if header_key != API_KEY:
return jsonify({"success": False, "error": "Invalid or missing API key"}), 403
return f(*args, **kwargs)
return wrapper
# ── Option parsing ───────────────────────────────────────────────────
def parse_options(src: dict) -> dict:
bool_fields = [
"paginate_output", "force_ocr", "disable_image_extraction",
"use_llm", "redo_inline_math", "strip_existing_ocr", "debug",
"paginate", "skip_cache", "save_checkpoint",
"disable_image_captions", "fence_synthetic_captions",
"token_efficient_markdown", "add_block_ids",
"include_markdown_in_chunks", "word_bboxes",
]
str_fields = [
"page_range", "output_format", "processors", "config_json",
"converter_cls", "llm_service", "block_correction_prompt",
"langs", "mode", "file_url", "additional_config",
"extras", "pipeline_id", "webhook_url", "processing_location",
"page_schema", "segmentation_schema", "model_override_settings",
]
int_fields = ["max_pages", "workflowstepdata_id"]
opts = {}
for k in bool_fields:
v = src.get(k)
if isinstance(v, str):
opts[k] = v.lower() in ("true", "1", "yes")
elif v is not None:
opts[k] = bool(v)
for k in str_fields:
v = src.get(k)
if v is not None and v != "":
opts[k] = str(v)
for k in int_fields:
v = src.get(k)
if v is not None:
opts[k] = int(v)
return opts
def build_options(**extra: Any) -> Dict[str, Any]:
opts: Dict[str, Any] = {}
for k, v in extra.items():
if v is not None:
opts[k] = v
opts.setdefault("output_format", "markdown")
opts.setdefault("force_ocr", False)
opts.setdefault("paginate_output", False)
opts.setdefault("page_range", None)
opts.setdefault("disable_multiprocessing", True)
opts.setdefault("disable_image_extraction", False)
opts.setdefault("output_dir", marker_settings.OUTPUT_DIR)
opts.setdefault("llm_service", DEFAULT_LLM_SERVICE)
if DEFAULT_OPENAI_BASE_URL:
opts.setdefault("openai_base_url", DEFAULT_OPENAI_BASE_URL)
if DEFAULT_OPENAI_API_KEY:
opts.setdefault("openai_api_key", DEFAULT_OPENAI_API_KEY)
if DEFAULT_OPENAI_MODEL:
opts.setdefault("openai_model", DEFAULT_OPENAI_MODEL)
if OLLAMA_HOST:
opts.setdefault("ollama_base_url", OLLAMA_HOST)
if DEESEEK_OCR_MODEL:
opts.setdefault("ollama_model", DEESEEK_OCR_MODEL)
config_parser = ConfigParser(opts)
return config_parser.generate_config_dict()
# ── Conversion core ──────────────────────────────────────────────────
def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix="." + filename.rsplit(".", 1)[-1] if "." in filename else "")
try:
tmp.write(raw_bytes)
tmp.close()
filepath = tmp.name
config_dict = build_options(**opts)
config_dict["disable_tqdm"] = True
model_dict = get_model_dict()
parsed_opts = {k: v for k, v in opts.items() if v is not None}
for key in ("output_format", "llm_service", "openai_base_url", "openai_api_key", "openai_model"):
if key not in parsed_opts and key in config_dict:
parsed_opts[key] = config_dict[key]
parsed = ConfigParser(parsed_opts)
converter = PdfConverter(
config=config_dict,
artifact_dict=model_dict,
processor_list=parsed.get_processors(),
renderer=parsed.get_renderer(),
llm_service=parsed.get_llm_service(),
)
rendered = converter(filepath)
text, _, images = text_from_rendered(rendered)
except Exception as exc:
traceback.print_exc()
return {"success": False, "error": str(exc)}
finally:
tmp.close()
if os.path.exists(tmp.name):
os.remove(tmp.name)
encoded_images: Dict[str, str] = {}
img_fmt = marker_settings.OUTPUT_IMAGE_FORMAT
for k, img in images.items():
buf = io.BytesIO()
img.save(buf, format=img_fmt)
encoded_images[k] = base64.b64encode(buf.getvalue()).decode("utf-8")
return {
"format": opts.get("output_format", "markdown"),
"output": text,
"images_b64": encoded_images,
"metadata": rendered.metadata if hasattr(rendered, 'metadata') else {},
"success": True,
}
def convert_with_ocr_backend(raw_bytes: bytes, filename: str, **opts: Any) -> dict:
if OCR_BACKEND == "deepseek":
page_range = opts.get("page_range")
max_pages = opts.get("max_pages")
ocr_result = dsocr.ocr_pdf(raw_bytes, max_pages=max_pages, page_range=page_range)
return {
"format": opts.get("output_format", "markdown"),
"output": ocr_result.get("text", ""),
"images_b64": {},
"metadata": {"page_count": ocr_result.get("page_count", 0), "ocr_backend": "deepseek"},
"success": ocr_result.get("success", False),
"page_count": ocr_result.get("page_count", 0),
}
return convert_file_bytes(raw_bytes, filename, **opts)
# ── Background worker ────────────────────────────────────────────────
# The job queue (submit_job, _job_queue, _job_cond) lives in request_store.py
# so both the Flask app and gunicorn's post_fork hook can access it.
# ── Extraction / Segmentation helpers ────────────────────────────────
def _apply_extraction_schema(markdown_text: str, schema: dict) -> dict:
result = {}
for field_name, field_def in schema.items():
if isinstance(field_def, dict):
desc = field_def.get("description", "")
ftype = field_def.get("type", "string")
result[field_name] = _extract_field(markdown_text, field_name, desc, ftype)
else:
result[field_name] = _extract_field(markdown_text, field_name, str(field_def), "string")
return result
def _extract_field(text: str, name: str, desc: str, ftype: str) -> Any:
import re
patterns = [
re.compile(rf"(?:{re.escape(name)}\s*:?\s*(.*?))(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL),
re.compile(rf"(?:{re.escape(desc)}\s*:?\s*(.*?))(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL),
]
for pat in patterns:
m = pat.search(text)
if m:
val = m.group(1).strip()
if ftype == "number":
try:
return float(val)
except ValueError:
return val
return val
return None
def _apply_segmentation_schema(markdown_text: str, schema: dict) -> list[dict]:
segments = []
for seg_name, seg_desc in schema.items():
if isinstance(seg_desc, str):
desc_text = seg_desc
elif isinstance(seg_desc, dict):
desc_text = seg_desc.get("description", seg_name)
else:
desc_text = str(seg_desc)
import re
pattern = re.compile(rf"(?:{re.escape(desc_text)})(.*?)(?=(?:{'|'.join(re.escape(str(v)) if isinstance(v, str) else re.escape(str(v.get('description', ''))) for v in schema.values())})|\Z)", re.IGNORECASE | re.DOTALL)
m = pattern.search(markdown_text)
if m:
segments.append({"name": seg_name, "description": desc_text, "content": m.group(1).strip()})
return segments
def _extract_tables(conv_result: dict) -> list[dict]:
output = conv_result.get("output", "")
if conv_result.get("format") == "json":
try:
data = json.loads(output) if isinstance(output, str) else output
if isinstance(data, dict):
return data.get("tables", [])
if isinstance(data, list):
return [item for item in data if "table" in str(item).lower() or "rows" in str(item)]
except (json.JSONDecodeError, TypeError):
pass
import re
tables = []
table_pattern = re.compile(r"\|(.+)\|[\s\S]*?(?=\n\n|\Z)", re.MULTILINE)
for i, m in enumerate(table_pattern.finditer(output)):
tables.append({"index": i, "table": m.group(0).strip()})
return tables
# ── File management ──────────────────────────────────────────────────
def _file_storage_path() -> Path:
p = Path(os.environ.get("UPLOAD_DIR", "/app/uploads"))
p.mkdir(parents=True, exist_ok=True)
return p
def _get_file_metadata(file_id: str) -> dict | None:
meta_path = _file_storage_path() / file_id / "metadata.json"
if not meta_path.exists():
return None
return json.loads(meta_path.read_text())
def _submit_and_poll(endpoint: str, raw_bytes: bytes, filename: str, form: dict | None = None, opts: dict | None = None) -> Response:
if opts is None:
opts = parse_options(form or {})
request_id = request_store.submit_job(endpoint, raw_bytes, filename, opts)
check_url = f"{BASE_URL}/api/v1/{endpoint}/{request_id}"
return jsonify({
"success": True,
"request_id": request_id,
"request_check_url": check_url,
})
def _poll_result(endpoint: str, request_id: str) -> Response:
entry = request_store.get_request(request_id)
if entry is None:
return jsonify({"success": False, "error": "Request not found"}), 404
if entry["status"] == "processing":
return jsonify({"status": "processing"})
result = request_store.get_result(request_id)
if result is None:
return jsonify({"status": "complete", "success": False, "error": "Result not found"}), 500
fmt = result.get("format", "markdown")
payload = {
"status": "complete",
"success": result.get("success", False),
"error": result.get("error"),
"output_format": fmt,
}
output_text = result.get("output", "")
if fmt in ("markdown", "html"):
payload[fmt] = output_text
elif fmt == "json":
try:
payload["json"] = json.loads(output_text) if output_text else None
except (json.JSONDecodeError, TypeError):
payload["json"] = output_text
elif fmt == "chunks":
payload["chunks"] = output_text
payload["images"] = result.get("images_b64", {})
payload["metadata"] = result.get("metadata", {})
payload["page_count"] = result.get("page_count")
return jsonify(payload)
# ── Flask app factory ────────────────────────────────────────────────
def create_app() -> Flask:
app = Flask(__name__)
# Start cleanup thread for expired results
request_store.start_cleanup_thread()
HTML_DOCS = r"""
marker-api
marker-api
Datalab-compatible document conversion API. Convert PDFs, images, documents to Markdown/HTML/JSON.
OCR Backend: {ocr_backend}
Datalab-compatible Endpoints
| Method | Path | Description |
| POST | /api/v1/convert | Convert document (async, returns request_id + check_url) |
| GET | /api/v1/convert/<id> | Poll conversion result |
| POST | /api/v1/extract | Structured extraction with JSON schema |
| GET | /api/v1/extract/<id> | Poll extraction result |
| POST | /api/v1/segment | Document segmentation with JSON schema |
| GET | /api/v1/segment/<id> | Poll segmentation result |
| POST | /api/v1/ocr | [DEPRECATED] OCR-only extraction |
| GET | /api/v1/ocr/<id> | Poll OCR result |
| POST | /api/v1/table_rec | [DEPRECATED] Table recognition |
| GET | /api/v1/table_rec/<id> | Poll table recognition result |
| POST | /api/v1/marker | [DEPRECATED] Sync conversion (returns directly) |
| POST | /api/v1/create-document | Create DOCX from markdown |
File Management
| Method | Path | Description |
| POST | /api/v1/files/upload | Request file upload URL |
| GET | /api/v1/files | List uploaded files |
| GET | /api/v1/files/<id> | Get file metadata |
| GET | /api/v1/files/<id>/download | Get file download URL |
| DELETE | /api/v1/files/<id> | Delete file |
Legacy Endpoints
| Method | Path | Description |
| POST | /marker | Sync conversion (original) |
| POST | /v1/conversions | Sync conversion (original) |
| POST | /v1/files/convert | Sync conversion via base64 JSON |
Authentication
All /api/v1/* endpoints accept X-API-Key header (or Authorization: Bearer).
POST /api/v1/convert
curl -X POST http://localhost:8001/api/v1/convert \\
-H "X-API-Key: YOUR_KEY" \\
-F "file=@document.pdf" \\
-F "output_format=markdown" \\
-F "mode=balanced"
Poll for results
curl http://localhost:8001/api/v1/convert/REQUEST_ID \\
-H "X-API-Key: YOUR_KEY"
Parameters
| Parameter | Type | Default | Description |
| file | file | required | Document to convert |
| output_format | string | markdown | markdown, html, json, chunks |
| mode | string | fast | fast, balanced, accurate |
| page_range | string | all | e.g. "0,5-10" |
| max_pages | int | all | Max pages to process |
| paginate | bool | false | Add page delimiters |
Environment
| Variable | Default | Description |
| OCR_BACKEND | marker | "marker" (marker-pdf) or "deepseek" (deepseek-ocr via ollama) |
| API_KEY | (none) | API key for X-API-Key auth (empty = disabled) |
| DEEPSEEK_OLLAMA_HOST | http://localhost:11434 | Ollama host for deepseek-ocr |
| DEEPSEEK_OCR_MODEL | deepseek-ocr:latest | Model name for deepseek-ocr |
| OLLAMA_HOST | http://localhost:11435 | Ollama host for LLM correction |
| DEESEEK_OCR_MODEL | deepseek-ocr | LLM correction model name |
| TORCH_DEVICE | auto | cpu, cuda, rocm |
| PORT | 8001 | Listening port |
| GUNICORN_WORKERS | 3 | Number of workers |
Supported Formats
{formats}
"""
# ── Legacy /info endpoints ───────────────────────────────────────
@app.route("/")
def docs():
body = HTML_DOCS.replace("{ocr_backend}", OCR_BACKEND).replace("{formats}", ", ".join(SUPPORTED_DISPLAY))
return body
@app.route("/health")
def health():
try:
device = TORCH_DEVICE or (marker_settings.TORCH_DEVICE_MODEL if hasattr(marker_settings, 'TORCH_DEVICE_MODEL') else 'auto')
except Exception:
device = "unknown"
return jsonify({
"status": "ok",
"ollama": OLLAMA_HOST,
"ocr_backend": OCR_BACKEND,
"amd_compute": AMD_COMPUTE,
"torch_device": device,
"supported_formats": SUPPORTED_DISPLAY,
"provider": "flask",
"default_llm_service": DEFAULT_LLM_SERVICE,
"default_use_llm": DEFAULT_USE_LLM,
"openai_base_url": DEFAULT_OPENAI_BASE_URL or None,
"openai_model": DEFAULT_OPENAI_MODEL or None,
})
# ── Existing original endpoints (unchanged behavior) ────────────
@app.route("/marker", methods=["POST"])
def convert_sync():
if "file" in request.files:
file = request.files["file"]
filename = file.filename or "file"
raw = file.read()
fmt = request.form.get("output_format", "markdown")
opts = parse_options(request.form)
opts["output_format"] = fmt
result = convert_with_ocr_backend(raw, filename, **opts)
if not result["success"]:
return jsonify(result), 500
if fmt == "markdown":
return Response(
result["output"],
mimetype="text/plain",
headers={"Content-Disposition": f'attachment; filename="{filename.rsplit(".", 1)[0]}.md"'},
)
return jsonify(result)
if request.is_json:
data = request.get_json()
if "file_b64" not in data or not data.get("filename"):
return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400
raw = base64.b64decode(data["file_b64"])
fmt = data.get("output_format", "markdown")
opts = parse_options(data)
opts["output_format"] = fmt
result = convert_with_ocr_backend(raw, data["filename"], **opts)
return jsonify(result)
return jsonify({"error": "No file provided. Use multipart/form-data or JSON with 'file_b64'."}), 400
@app.route("/v1/conversions", methods=["POST"])
def convert_async_style():
if request.is_json:
data = request.get_json()
if "file_b64" not in data or not data.get("filename"):
return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400
raw = base64.b64decode(data["file_b64"])
fmt = data.get("output_format", "markdown")
opts = parse_options(data)
opts["output_format"] = fmt
result = convert_with_ocr_backend(raw, data["filename"], **opts)
if not result["success"]:
return jsonify(result), 500
return jsonify({
"id": str(uuid.uuid4()),
"filename": data["filename"],
"format": fmt,
"output": result["output"],
"success": True,
"images_b64": result.get("images_b64", {}),
"metadata": result.get("metadata", {}),
})
if "file" in request.files:
file = request.files["file"]
filename = file.filename or "file"
raw = file.read()
fmt = request.form.get("output_format", "markdown")
opts = parse_options(request.form)
opts["output_format"] = fmt
result = convert_with_ocr_backend(raw, filename, **opts)
if not result["success"]:
return jsonify(result), 500
return jsonify({
"id": str(uuid.uuid4()),
"filename": filename,
"format": fmt,
"output": result["output"],
"success": True,
"images_b64": result.get("images_b64", {}),
"metadata": result.get("metadata", {}),
})
return jsonify({"error": "No file provided."}), 400
@app.route("/v1/files/convert", methods=["POST"])
def convert_files():
if "file" in request.files:
file = request.files["file"]
filename = file.filename or "file"
raw = file.read()
fmt = request.form.get("output_format", "markdown")
opts = parse_options(request.form)
opts["output_format"] = fmt
result = convert_with_ocr_backend(raw, filename, **opts)
filename_var = filename
elif request.is_json:
data = request.get_json()
if "file_b64" not in data or not data.get("filename"):
return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400
raw = base64.b64decode(data["file_b64"])
fmt = data.get("output_format", "markdown")
opts = parse_options(data)
opts["output_format"] = fmt
result = convert_with_ocr_backend(raw, data["filename"], **opts)
filename_var = data["filename"]
else:
return jsonify({"error": "No file provided."}), 400
if not result["success"]:
return jsonify(result), 500
return jsonify({
"filename": filename_var,
"format": fmt,
"output": result["output"],
"images_b64": result.get("images_b64", {}),
"metadata": result.get("metadata", {}),
})
# ── New Datalab-compatible endpoints ─────────────────────────────
# POST /api/v1/convert — async submit
@app.route("/api/v1/convert", methods=["POST"])
@require_api_key
def api_convert_submit():
if "file" in request.files:
file = request.files["file"]
raw = file.read()
filename = file.filename or "file"
elif request.is_json:
data = request.get_json()
if "file_b64" in data:
raw = base64.b64decode(data["file_b64"])
filename = data.get("filename", "file")
elif "file_url" in data:
import requests as req
resp = req.get(data["file_url"], timeout=120)
resp.raise_for_status()
raw = resp.content
filename = data.get("filename", data["file_url"].rsplit("/", 1)[-1] or "file")
else:
return jsonify({"error": "Provide file, file_b64, or file_url"}), 400
else:
return jsonify({"error": "No file provided"}), 400
source = request.form if request.form else (request.get_json() if request.is_json else {})
opts = parse_options(source)
opts["output_format"] = source.get("output_format", "markdown")
return _submit_and_poll("convert", raw, filename, opts=opts)
# GET /api/v1/convert/ — poll
@app.route("/api/v1/convert/", methods=["GET"])
@require_api_key
def api_convert_poll(request_id: str):
return _poll_result("convert", request_id)
# POST /api/v1/marker — deprecated (Datalab compat: async submit-and-poll)
@app.route("/api/v1/marker", methods=["POST"])
@require_api_key
def api_marker_deprecated():
if "file" in request.files:
file = request.files["file"]
raw = file.read()
filename = file.filename or "file"
elif request.is_json:
data = request.get_json()
if "file_b64" in data:
raw = base64.b64decode(data["file_b64"])
filename = data.get("filename", "file")
else:
return jsonify({"error": "Provide file or file_b64"}), 400
else:
return jsonify({"error": "No file provided"}), 400
source = request.form if request.form else (request.get_json() if request.is_json else {})
opts = parse_options(source)
opts.setdefault("output_format", "markdown")
resp = _submit_and_poll("convert", raw, filename, opts=opts)
if isinstance(resp, Response):
resp.headers["Warning"] = "299 marker-api \"POST /api/v1/marker is deprecated, use /api/v1/convert\""
return resp
# POST /api/v1/extract — structured extraction
@app.route("/api/v1/extract", methods=["POST"])
@require_api_key
def api_extract_submit():
if "file" in request.files:
file = request.files["file"]
raw = file.read()
filename = file.filename or "file"
elif request.is_json:
data = request.get_json()
if "file_b64" in data:
raw = base64.b64decode(data["file_b64"])
filename = data.get("filename", "file")
else:
return jsonify({"error": "Provide file or file_b64"}), 400
else:
return jsonify({"error": "No file provided"}), 400
source = request.form if request.form else (request.get_json() if request.is_json else {})
opts = parse_options(source)
opts.setdefault("output_format", "markdown")
return _submit_and_poll("extract", raw, filename, opts=opts)
# GET /api/v1/extract/
@app.route("/api/v1/extract/", methods=["GET"])
@require_api_key
def api_extract_poll(request_id: str):
return _poll_result("extract", request_id)
# POST /api/v1/segment — document segmentation
@app.route("/api/v1/segment", methods=["POST"])
@require_api_key
def api_segment_submit():
if "file" in request.files:
file = request.files["file"]
raw = file.read()
filename = file.filename or "file"
elif request.is_json:
data = request.get_json()
if "file_b64" in data:
raw = base64.b64decode(data["file_b64"])
filename = data.get("filename", "file")
else:
return jsonify({"error": "Provide file or file_b64"}), 400
else:
return jsonify({"error": "No file provided"}), 400
source = request.form if request.form else (request.get_json() if request.is_json else {})
opts = parse_options(source)
opts.setdefault("output_format", "markdown")
return _submit_and_poll("segment", raw, filename, opts=opts)
# GET /api/v1/segment/
@app.route("/api/v1/segment/", methods=["GET"])
@require_api_key
def api_segment_poll(request_id: str):
return _poll_result("segment", request_id)
# POST /api/v1/ocr — deprecated OCR-only endpoint
@app.route("/api/v1/ocr", methods=["POST"])
@require_api_key
def api_ocr_submit():
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
raw = file.read()
filename = file.filename or "file"
opts = parse_options(request.form)
return _submit_and_poll("ocr", raw, filename, opts=opts)
# GET /api/v1/ocr/
@app.route("/api/v1/ocr/", methods=["GET"])
@require_api_key
def api_ocr_poll(request_id: str):
return _poll_result("ocr", request_id)
# POST /api/v1/table_rec — deprecated table recognition
@app.route("/api/v1/table_rec", methods=["POST"])
@require_api_key
def api_table_rec_submit():
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
raw = file.read()
filename = file.filename or "file"
opts = parse_options(request.form)
return _submit_and_poll("table_rec", raw, filename, opts=opts)
# GET /api/v1/table_rec/
@app.route("/api/v1/table_rec/", methods=["GET"])
@require_api_key
def api_table_rec_poll(request_id: str):
return _poll_result("table_rec", request_id)
# POST /api/v1/create-document — create DOCX from markdown
@app.route("/api/v1/create-document", methods=["POST"])
@require_api_key
def api_create_document():
data = request.get_json() if request.is_json else {}
if not data or "markdown" not in data:
return jsonify({"error": "JSON body must include 'markdown'"}), 400
md = data["markdown"]
output_format = data.get("output_format", "docx")
try:
import subprocess
import base64
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
f.write(md)
md_path = f.name
out_path = md_path.replace(".md", f".{output_format}")
if output_format == "docx":
subprocess.run(["pandoc", md_path, "-o", out_path], capture_output=True, timeout=60)
with open(out_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
os.unlink(out_path)
os.unlink(md_path)
return jsonify({
"success": True,
"output_format": "docx",
"output_base64": b64,
})
else:
return jsonify({"error": f"Unsupported output format: {output_format}"}), 400
except FileNotFoundError:
return jsonify({"error": "pandoc not installed, cannot create document"}), 500
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
# ── File management endpoints ────────────────────────────────────
@app.route("/api/v1/files/upload", methods=["POST"])
@require_api_key
def api_file_upload():
data = request.get_json() if request.is_json else {}
filename = data.get("filename", "file")
content_type = data.get("content_type", "application/octet-stream")
import time as tmod
file_id = str(uuid.uuid4())
file_dir = _file_storage_path() / file_id
file_dir.mkdir(parents=True, exist_ok=True)
metadata = {
"file_id": file_id,
"original_filename": filename,
"filename": filename,
"content_type": content_type,
"reference": f"datalab://file-{file_id}",
"created_at": time.time(),
"created": tmod.strftime("%Y-%m-%dT%H:%M:%SZ", tmod.gmtime()),
"file_size": None,
"upload_status": "pending",
"error": None,
}
(file_dir / "metadata.json").write_text(json.dumps(metadata))
import secrets
token = secrets.token_urlsafe(32)
upload_url = f"{BASE_URL}/api/v1/files/{file_id}/upload/{token}"
(file_dir / "upload_token.txt").write_text(token)
return jsonify({
"file_id": file_id,
"upload_url": upload_url,
"reference": f"datalab://file-{file_id}",
})
@app.route("/api/v1/files//upload/", methods=["PUT"])
def api_file_upload_put(file_id: str, token: str):
file_dir = _file_storage_path() / file_id
token_path = file_dir / "upload_token.txt"
if not token_path.exists() or token_path.read_text().strip() != token:
return jsonify({"error": "Invalid upload token"}), 403
(file_dir / "content").write_bytes(request.data)
metadata_path = file_dir / "metadata.json"
if metadata_path.exists():
import time as tmod
meta = json.loads(metadata_path.read_text())
meta["uploaded_at"] = tmod.time()
meta["file_size"] = len(request.data)
meta["upload_status"] = "completed"
metadata_path.write_text(json.dumps(meta))
return jsonify({"success": True})
@app.route("/api/v1/files", methods=["GET"])
@require_api_key
def api_file_list():
storage = _file_storage_path()
limit = int(request.args.get("limit", 50))
offset = int(request.args.get("offset", 0))
files = []
for child in sorted(storage.iterdir(), reverse=True):
if child.is_dir():
meta = _get_file_metadata(child.name)
if meta:
files.append(meta)
return jsonify({"files": files[offset:offset + limit], "total": len(files)})
@app.route("/api/v1/files/", methods=["GET"])
@require_api_key
def api_file_get(file_id: str):
meta = _get_file_metadata(file_id)
if meta is None:
return jsonify({"error": "File not found"}), 404
return jsonify(meta)
@app.route("/api/v1/files//confirm", methods=["GET"])
@require_api_key
def api_file_confirm(file_id: str):
meta = _get_file_metadata(file_id)
if meta is None:
return jsonify({"error": "File not found"}), 404
file_dir = _file_storage_path() / file_id
content_path = file_dir / "content"
if not content_path.exists():
return jsonify({"error": "File content not uploaded yet"}), 400
return jsonify({"success": True, "file_id": file_id})
@app.route("/api/v1/files//download", methods=["GET"])
@require_api_key
def api_file_download(file_id: str):
meta = _get_file_metadata(file_id)
if meta is None:
return jsonify({"error": "File not found"}), 404
expires_in = int(request.args.get("expires_in", 3600))
import secrets
token = secrets.token_urlsafe(32)
file_dir = _file_storage_path() / file_id
(file_dir / "download_token.txt").write_text(token)
download_url = f"{BASE_URL}/api/v1/files/{file_id}/download/{token}?expires_in={expires_in}"
return jsonify({"download_url": download_url})
@app.route("/api/v1/files//download/", methods=["GET"])
def api_file_download_token(file_id: str, token: str):
file_dir = _file_storage_path() / file_id
token_path = file_dir / "download_token.txt"
if not token_path.exists() or token_path.read_text().strip() != token:
return jsonify({"error": "Invalid download token"}), 403
content_path = file_dir / "content"
if not content_path.exists():
return jsonify({"error": "File content not found"}), 404
meta = _get_file_metadata(file_id) or {}
return Response(
content_path.read_bytes(),
mimetype=meta.get("content_type", "application/octet-stream"),
headers={"Content-Disposition": f'attachment; filename="{meta.get("filename", "file")}"'},
)
@app.route("/api/v1/files/", methods=["DELETE"])
@require_api_key
def api_file_delete(file_id: str):
file_dir = _file_storage_path() / file_id
if file_dir.exists():
import shutil
shutil.rmtree(str(file_dir))
return jsonify({"success": True})
# ── Thumbnails ───────────────────────────────────────────────────
@app.route("/api/v1/thumbnails/", methods=["GET"])
@require_api_key
def api_thumbnails(lookup_key: str):
thumb_width = int(request.args.get("thumb_width", 300))
page_range = request.args.get("page_range")
entry = request_store.get_request(lookup_key)
if entry is None:
return jsonify({"success": False, "error": "Request not found"}), 404
result = request_store.get_result(lookup_key)
if result is None:
return jsonify({"success": False, "error": "No result found"}), 404
metadata = result.get("metadata", {})
page_count = metadata.get("page_count", 0) if isinstance(metadata, dict) else 0
if page_range:
pages = []
for part in page_range.split(","):
part = part.strip()
if "-" in part:
a, b = part.split("-", 1)
pages.extend(range(int(a.strip()), int(b.strip()) + 1))
else:
pages.append(int(part))
else:
pages = list(range(page_count))
thumbnails = []
for p in pages:
if p < page_count:
thumbnails.append("") # placeholder — no rendered page images stored
return jsonify({
"success": True,
"thumbnails": thumbnails,
})
return app
app_instance = create_app()
if __name__ == "__main__":
_configure_env()
_marker_dict = create_model_dict()
port = int(os.environ.get("PORT", "8001"))
host = os.environ.get("HOST", "0.0.0.0")
print("=" * 60)
print("marker-api starting")
print(f" OCR_BACKEND = {OCR_BACKEND}")
print(f" API_KEY = {'set' if API_KEY else '(not set)'}")
print(f" OLLAMA_HOST = {OLLAMA_HOST}")
print(f" TORCH_DEVICE = {TORCH_DEVICE or '(auto)'}")
print(f" LISTENING ON = {host}:{port}")
print(f" FORMATS = {', '.join(SUPPORTED_DISPLAY)}")
print("=" * 60)
app_instance.run(host=host, port=port, debug=(os.environ.get("FLASK_DEBUG", "0") == "1"), threaded=True)