From 1a627dc0999612fde3de1080d036dc8639e7ca6b Mon Sep 17 00:00:00 2001 From: Solaria Date: Sat, 14 Mar 2026 23:29:13 +0000 Subject: [PATCH] Add TeX Live API client and deployment configs --- deployments/texlive-api/README.md | 35 ++++++++ deployments/texlive-api/docker-compose.yml | 18 ++++ deployments/texlive-api/texlive_api.py | 74 +++++++++++++++ opus_orchestrator/texlive_client.py | 100 +++++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 deployments/texlive-api/README.md create mode 100644 deployments/texlive-api/docker-compose.yml create mode 100644 deployments/texlive-api/texlive_api.py create mode 100644 opus_orchestrator/texlive_client.py diff --git a/deployments/texlive-api/README.md b/deployments/texlive-api/README.md new file mode 100644 index 0000000..e610bef --- /dev/null +++ b/deployments/texlive-api/README.md @@ -0,0 +1,35 @@ +# TeX Live API + +Lightweight LaTeX compilation API. + +## Setup + +```bash +# Install dependencies +pip install flask + +# Install LaTeX +# macOS +brew install texlive + +# Ubuntu +sudo apt install texlive-xelatex + +# Run +python texlive_api.py +``` + +## Usage + +```bash +# Compile +curl -X POST http://localhost:8080/compile \ + -H "Content-Type: application/json" \ + -d '{"tex": "\\documentclass{article}\\n\\begin{document}\\nHello\\n\\end{document}"}' +``` + +## Docker + +```bash +docker-compose up -d +``` diff --git a/deployments/texlive-api/docker-compose.yml b/deployments/texlive-api/docker-compose.yml new file mode 100644 index 0000000..a9facda --- /dev/null +++ b/deployments/texlive-api/docker-compose.yml @@ -0,0 +1,18 @@ +# TeX Live API Service +# Run with: docker-compose up -d + +version: '3.8' + +services: + texlive-api: + image: alex11br/texlive-api + ports: + - "8080:8080" + volumes: + - texlive-cache:/root/.texlive + environment: + - TEXLIVE_ENGINE=xelatex + - MAX_TIMEOUT=180 + +volumes: + texlive-cache: diff --git a/deployments/texlive-api/texlive_api.py b/deployments/texlive-api/texlive_api.py new file mode 100644 index 0000000..19e94bd --- /dev/null +++ b/deployments/texlive-api/texlive_api.py @@ -0,0 +1,74 @@ +# Simple TeX Live API Service +# Run: python texlive_api.py + +from flask import Flask, request, jsonify, send_file +import subprocess +import tempfile +import os +import base64 +from pathlib import Path + +app = Flask(__name__) + +ALLOWED_ENGINES = ['xelatex', 'pdflatex', 'lualatex'] +MAX_TIMEOUT = 180 + + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({"status": "ok"}) + + +@app.route('/compile', methods=['POST']) +def compile(): + data = request.get_json() + + tex_content = data.get('tex') + engine = data.get('engine', 'xelatex') + timeout = min(data.get('timeout', MAX_TIMEOUT), MAX_TIMEOUT) + + if not tex_content: + return jsonify({"error": "No tex content provided"}), 400 + + if engine not in ALLOWED_ENGINES: + return jsonify({"error": f"Invalid engine: {engine}"}), 400 + + # Create temp directory + with tempfile.TemporaryDirectory() as tmpdir: + tex_file = Path(tmpdir) / "input.tex" + tex_file.write_text(tex_content) + + # Compile + try: + result = subprocess.run( + [engine, "-interaction=nonstopmode", tex_file.name], + cwd=tmpdir, + capture_output=True, + timeout=timeout, + ) + + pdf_file = tex_file.with_suffix('.pdf') + + if pdf_file.exists(): + pdf_data = base64.b64encode(pdf_file.read_bytes()).decode() + return jsonify({ + "success": True, + "pdf": pdf_data, + "engine": engine, + }) + else: + # Return error log + return jsonify({ + "success": False, + "error": "PDF not generated", + "log": result.stderr.decode()[-2000:], + }), 500 + + except subprocess.TimeoutExpired: + return jsonify({"error": "Compilation timeout"}), 504 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) diff --git a/opus_orchestrator/texlive_client.py b/opus_orchestrator/texlive_client.py new file mode 100644 index 0000000..9dcf8ba --- /dev/null +++ b/opus_orchestrator/texlive_client.py @@ -0,0 +1,100 @@ +"""TeX Live API Client for Opus Orchestrator. + +Compiles LaTeX via remote TeX Live API service. +""" + +import json +import base64 +from typing import Optional, Dict, Any +from pathlib import Path + + +class TeXLiveClient: + """Client for TeX Live API service.""" + + def __init__(self, base_url: str = "http://localhost:8080"): + """Initialize TeX Live client. + + Args: + base_url: Base URL of TeX Live API service + """ + self.base_url = base_url.rstrip("/") + + def compile( + self, + tex_content: str, + engine: str = "xelatex", + timeout: int = 120, + ) -> Dict[str, Any]: + """Compile LaTeX via API. + + Args: + tex_content: LaTeX source code + engine: LaTeX engine (xelatex, pdflatex, lualatex) + timeout: Compilation timeout in seconds + + Returns: + Compilation result with PDF data + """ + import requests + + response = requests.post( + f"{self.base_url}/compile", + json={ + "tex": tex_content, + "engine": engine, + "timeout": timeout, + }, + timeout=timeout + 10, + ) + + if response.status_code != 200: + raise RuntimeError(f"TeX Live API error: {response.text}") + + result = response.json() + + if result.get("error"): + raise RuntimeError(f"LaTeX compilation failed: {result['error']}") + + return result + + def compile_file( + self, + tex_path: str, + engine: str = "xelatex", + ) -> bytes: + """Compile LaTeX file via API. + + Args: + tex_path: Path to .tex file + engine: LaTeX engine + + Returns: + Compiled PDF as bytes + """ + tex_content = Path(tex_path).read_text() + result = self.compile(tex_content, engine) + + # Decode PDF from base64 + pdf_data = base64.b64decode(result["pdf"]) + return pdf_data + + +def compile_via_texlive( + tex_content: str, + base_url: str = "http://localhost:8080", + engine: str = "xelatex", +) -> bytes: + """Convenience function to compile LaTeX via TeX Live API. + + Args: + tex_content: LaTeX source + base_url: TeX Live API URL + engine: LaTeX engine + + Returns: + Compiled PDF bytes + """ + client = TeXLiveClient(base_url) + result = client.compile(tex_content, engine) + return base64.b64decode(result["pdf"])