from __future__ import annotations import base64 import io import json import os import tempfile import traceback import uuid from typing import Any, Dict, Optional import torch from flask import Flask, jsonify, request, Response import PIL.Image # Marker imports 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 # Ollama / OCR fallback configuration OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://10.0.1.127:11434") DEESEEK_OCR_MODEL = os.environ.get("DEESEEK_OCR_MODEL", "deepseek-ocr") 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") _marker_dict: Optional[Dict[str, Any]] = None # Collect supported file extensions 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}) # Env-var overrides for default LLM service 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", "") def _configure_env(): """Apply AMD GPU / environment overrides before torch loads.""" if AMD_COMPUTE and not TORCH_DEVICE: os.environ["TORCH_DEVICE"] = "cuda" os.environ["TORCH_DEVICE_MODEL"] = "cuda" os.environ["HSA_OVERRIDE_GFX_VERSION"] = "9.0.6" 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: _marker_dict = create_model_dict() return _marker_dict 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 # Ensure defaults are set 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) # Pass default LLM settings from environment if not explicitly provided 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() def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict: """Convert in-memory file bytes to markdown (or other format).""" 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() # Build parsed options, preserving server default LLM config when client omits it parsed_opts = {k: v for k, v in opts.items() if v is not None} for key in ("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 'rendered' in dir() else {}, "success": True, } def create_app() -> Flask: app = Flask(__name__) # ---- docs page ---- HTML_DOCS = r"""marker-api

marker-api

Convert PDFs, EPUBs, DOCX, XLSX, PPTX, HTML, and images to Markdown.

Endpoints

EndpointMethodDescription
/GETThis documentation page
/healthGETHealth check with configuration
/markerPOSTConvert a file (sync, returns result)
/v1/conversionsPOSTConvert a file (async-style, returns directly)
/v1/files/convertPOSTConvert a file via JSON body with base64

POST /marker (multipart/form-data)

curl -X POST http://localhost:8000/marker \
  -F "file=@document.pdf" \
  -F "force_ocr=false" \
  -F "page_range=0,5-10" \
  -F "output_format=markdown"

Returns the converted content directly as a file download.

POST /marker (application/json)

curl -X POST http://localhost:8000/marker \
  -H "Content-Type: application/json" \
  -d '{
    "file_b64": "base64-encoded-file-content",
    "filename": "document.pdf",
    "output_format": "markdown",
    "force_ocr": false
  }'

POST /v1/files/convert (application/json)

curl -X POST http://localhost:8000/v1/files/convert \
  -H "Content-Type: application/json" \
  -d '{
    "file_b64": "base64-encoded-file-content",
    "filename": "document.pdf",
    "output_format": "json",
    "page_range": "0,5-10"
  }'

Options

ParameterTypeDefaultDescription
file / file_b64file / stringrequiredThe file to convert
force_ocrboolfalseForce OCR on all pages
paginate_outputboolfalseSeparate pages with horizontal rules
output_formatstringmarkdownmarkdown, json, html, chunks
page_rangestringallComma-separated page numbers/ranges: "0,5-10"
disable_image_extractionboolfalseDisable extraction of embedded images
processorsstringautoComma-separated full module paths
config_jsonstringnonePath to JSON file with additional config
converter_clsstringauto-detectedFull module path of converter class
use_llmboolfalseUse an LLM to improve accuracy (requires LLM service)
llm_servicestringmarker.services.
ollama.OllamaService
LLM service class path: gemini, vertex, claude, openai, azure_openai, ollama
block_correction_promptstringnoneCustom prompt for LLM block correction
redo_inline_mathboolfalseRe-process inline math with LLM
strip_existing_ocrboolfalseRemove all existing OCR text and re-OCR
debugboolfalseEnable debug mode with additional logging

Supported Formats

{formats}

Environment Variables

VariableDefaultDescription
OLLAMA_HOSThttp://10.0.1.127:11434Ollama instance for OCR fallback
DEESEEK_OCR_MODELdeepseek-ocrOCR model name in Ollama
AMD_COMPUTEfalseEnable AMD ROCm GPU compute (set to "true")
TORCH_DEVICEautoPyTorch device: rocm, cuda, cpu
MODEL_DTYPEfloat32Model dtype: float32, bfloat16
PORT8000Listening port
HOST0.0.0.0Listening host
LLM_SERVICEmarker.services.ollama.OllamaServiceDefault LLM service class for use_llm
USE_LLMfalseDefault use_llm flag (true/false)
OPENAI_BASE_URLBase URL for OpenAI-compatible LLM service
OPENAI_API_KEYAPI key for OpenAI-compatible LLM service
OPENAI_MODELModel name for OpenAI-compatible LLM service
""" @app.route("/") def docs(): body = HTML_DOCS.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_model": DEESEEK_OCR_MODEL, "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, }) @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 = { "page_range": request.form.get("page_range"), "paginate_output": request.form.get("paginate_output", "false").lower() == "true", "force_ocr": request.form.get("force_ocr", "false").lower() == "true", "output_format": fmt, "disable_image_extraction": request.form.get("disable_image_extraction", "false").lower() == "true", "processors": request.form.get("processors"), "config_json": request.form.get("config_json"), "converter_cls": request.form.get("converter_cls"), "use_llm": request.form.get("use_llm", "false").lower() == "true", "llm_service": request.form.get("llm_service"), "block_correction_prompt": request.form.get("block_correction_prompt"), "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true", "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true", "debug": request.form.get("debug", "false").lower() == "true", } result = convert_file_bytes(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 = { "page_range": data.get("page_range"), "paginate_output": data.get("paginate_output", False), "force_ocr": data.get("force_ocr", False), "output_format": fmt, "disable_image_extraction": data.get("disable_image_extraction", False), "processors": data.get("processors"), "config_json": data.get("config_json"), "converter_cls": data.get("converter_cls"), "use_llm": data.get("use_llm", False), "llm_service": data.get("llm_service"), "block_correction_prompt": data.get("block_correction_prompt"), "redo_inline_math": data.get("redo_inline_math", False), "strip_existing_ocr": data.get("strip_existing_ocr", False), "debug": data.get("debug", False), } result = convert_file_bytes(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(): """Convert a file, returns the result directly (async-style naming for API compatibility).""" 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 = { "page_range": data.get("page_range"), "paginate_output": data.get("paginate_output", False), "force_ocr": data.get("force_ocr", False), "output_format": fmt, "disable_image_extraction": data.get("disable_image_extraction", False), "processors": data.get("processors"), "config_json": data.get("config_json"), "converter_cls": data.get("converter_cls"), "use_llm": data.get("use_llm", False), "llm_service": data.get("llm_service"), "block_correction_prompt": data.get("block_correction_prompt"), "redo_inline_math": data.get("redo_inline_math", False), "strip_existing_ocr": data.get("strip_existing_ocr", False), "debug": data.get("debug", False), } result = convert_file_bytes(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 = { "page_range": request.form.get("page_range"), "paginate_output": request.form.get("paginate_output", "false").lower() == "true", "force_ocr": request.form.get("force_ocr", "false").lower() == "true", "output_format": fmt, "disable_image_extraction": request.form.get("disable_image_extraction", "false").lower() == "true", "processors": request.form.get("processors"), "config_json": request.form.get("config_json"), "converter_cls": request.form.get("converter_cls"), "use_llm": request.form.get("use_llm", "false").lower() == "true", "llm_service": request.form.get("llm_service"), "block_correction_prompt": request.form.get("block_correction_prompt"), "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true", "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true", "debug": request.form.get("debug", "false").lower() == "true", } result = convert_file_bytes(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(): """Convert a file via JSON body with base64.""" 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 = { "page_range": request.form.get("page_range"), "paginate_output": request.form.get("paginate_output", False), "force_ocr": request.form.get("force_ocr", False), "output_format": fmt, "disable_image_extraction": request.form.get("disable_image_extraction", False), "processors": request.form.get("processors"), "config_json": request.form.get("config_json"), "converter_cls": request.form.get("converter_cls"), "use_llm": request.form.get("use_llm", "false").lower() == "true", "llm_service": request.form.get("llm_service"), "block_correction_prompt": request.form.get("block_correction_prompt"), "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true", "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true", "debug": request.form.get("debug", "false").lower() == "true", } result = convert_file_bytes(raw, filename, **opts) 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 = { "page_range": data.get("page_range"), "paginate_output": data.get("paginate_output", False), "force_ocr": data.get("force_ocr", False), "output_format": fmt, "disable_image_extraction": data.get("disable_image_extraction", False), "processors": data.get("processors"), "config_json": data.get("config_json"), "converter_cls": data.get("converter_cls"), "use_llm": data.get("use_llm", False), "llm_service": data.get("llm_service"), "block_correction_prompt": data.get("block_correction_prompt"), "redo_inline_math": data.get("redo_inline_math", False), "strip_existing_ocr": data.get("strip_existing_ocr", False), "debug": data.get("debug", False), } result = convert_file_bytes(raw, data["filename"], **opts) fmt = fmt else: return jsonify({"error": "No file provided."}), 400 if not result["success"]: return jsonify(result), 500 _filename = data.get("filename") if request.is_json else filename return jsonify({ "filename": _filename, "format": fmt, "output": result["output"], "images_b64": result.get("images_b64", {}), "metadata": result.get("metadata", {}), }) return app app_instance = create_app() if __name__ == "__main__": _configure_env() _marker_dict = create_model_dict() # pre-warm models port = int(os.environ.get("PORT", "8000")) host = os.environ.get("HOST", "0.0.0.0") print("=" * 60) print("marker-api starting") print(f" OLLAMA_HOST = {OLLAMA_HOST}") print(f" OCR_MODEL = {DEESEEK_OCR_MODEL}") print(f" AMD_COMPUTE = {AMD_COMPUTE}") print(f" TORCH_DEVICE = {TORCH_DEVICE or '(auto)'}") print(f" MODEL_DTYPE = {MODEL_DTYPE}") 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)