A Compiler API is any HTTP endpoint that accepts your project's files, runs
pdflatex, and returns a PDF. Set its URL in Settings and GitLaTeX stops using your
local pdflatex — every Compile goes to that URL instead. Useful when
you have no LaTeX install, or want one shared TeX box for several machines.
1. How it works
Your API only has to handle one round trip:
- GitLaTeX sends you a single
POSTcontaining every file in the project and the name of the main.tex. - You unpack those files, run
pdflatex, and reply with JSON holding the PDF as base64 — or an error message. - GitLaTeX saves the PDF into the project and shows it in the preview pane.
Anything you put in the error field is printed to the console pane at the bottom of the
editor. That console is the only place your users will see it, so send back the pdflatex log.
2. The request GitLaTeX sends you
| Method | POST |
|---|---|
| URL | Exactly what you typed in Settings. A value with no scheme gets https:// prepended. |
| Content-Type | application/json |
| Authorization | Bearer <your API key> — sent only when the API key field is non-empty. |
Body:
{
"main": "main.tex",
"engine": "pdflatex",
"files": [
{ "path": "main.tex", "content": "\\documentclass{article}\n..." },
{ "path": "chapters/intro.tex", "content": "\\section{Intro}\n..." },
{ "path": "refs.bib", "content": "@article{key, ...}" },
{ "path": "figures/plot.png", "base64": "iVBORw0KGgoAAAANSUhEUg..." }
]
}
main— the file picked in the toolbar's main-file dropdown. It is a repo-relative path, so it can be nested (thesis/main.tex), not just a bare filename. Defaults tomain.tex.engine— the engine chosen in Settings:pdflatex,xelatexorlualatex. Honour it if you can; fall back topdflatexif you only support one. Your API is responsible for running multiple passes and bibtex/biber — GitLaTeX's local multi-pass build cannot drive a remote compiler, so a single-pass API will return??for cross-references and unresolved citations.files— every file in the repo, flattened. Paths are repo-relative and always use forward slashes, on Windows too.- Two possible content keys. Text files arrive as
content(a UTF-8 string); binary files arrive asbase64. Always checkbase64first and fall back tocontent— the split is by file extension, so.css,.jsand.svgalso come through asbase64. - The
.gitfolder is excluded, and any single file over 5 MB is dropped from the bundle. - Everything else is sent, including the PDF from the previous compile. Ignore it —
pdflatexwill overwrite it anyway.
3. The response you must return
JSON, always. On success:
{ "success": true, "pdf": "data:application/pdf;base64,JVBERi0xLjUK..." }
On failure:
{ "error": "! Undefined control sequence.\nl.12 \\bogus" }
- Both
successandpdfare required for a compile to count.success: truewith nopdfis treated as a failure. - A base64 data URL is the recommended form. GitLaTeX strips the prefix and saves the bytes into your repository, so the PDF is a real file you can commit and push.
- A plain
https://…URL inpdfalso works — but it is only loaded into the preview iframe, never saved into the repo. - Put the
pdflatexlog inerror. It lands verbatim in the console pane, which is all your users get to debug with. - The status code is ignored on compile (only the body is read), so it is fine to return errors as
200. The Test button, however, does check for a 2xx status.
4. CORS is required
The request is made by the browser from the GitLaTeX page (http://localhost:5000 by default),
not by the Python server. That makes it cross-origin, and the JSON Content-Type plus the
Authorization header trigger a preflight. Your API must answer OPTIONS and send:
Access-Control-Allow-Origin: http://localhost:5000
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
A "Connection failed" or "Failed to fetch" message with nothing in your server log is almost always a missing preflight response, not a broken compile.
5. Reference implementation — Python / Flask
Complete and runnable. Needs pip install flask and a working pdflatex on the machine that runs it.
import base64, os, subprocess, tempfile
from flask import Flask, jsonify, request
app = Flask(__name__)
API_KEY = os.environ.get("COMPILE_API_KEY") # optional; unset = no auth
@app.after_request
def cors(resp):
resp.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*")
resp.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
return resp
@app.route("/compile", methods=["POST", "OPTIONS"])
def compile_tex():
if request.method == "OPTIONS":
return "", 204
if API_KEY and request.headers.get("Authorization") != "Bearer " + API_KEY:
return jsonify(error="Unauthorized"), 401
data = request.get_json(silent=True) or {}
main = (data.get("main") or "main.tex").replace("\\", "/").lstrip("/")
files = data.get("files") or []
with tempfile.TemporaryDirectory() as work:
for f in files:
rel = (f.get("path") or "").replace("\\", "/").lstrip("/")
parts = [p for p in rel.split("/") if p]
if not parts or ".." in parts:
continue # never write outside the work dir
dest = os.path.join(work, *parts)
os.makedirs(os.path.dirname(dest) or work, exist_ok=True)
blob = (base64.b64decode(f["base64"]) if f.get("base64") is not None
else (f.get("content") or "").encode("utf-8"))
with open(dest, "wb") as fh:
fh.write(blob)
# Run in the main file's own folder so \input paths and the output
# land where GitLaTeX expects them.
main_dir = os.path.dirname(os.path.join(work, *main.split("/"))) or work
main_name = os.path.basename(main)
cmd = ["pdflatex", "-interaction=nonstopmode", "-halt-on-error", main_name]
log = ""
try:
for _ in range(2): # twice, so refs and the ToC resolve
proc = subprocess.run(cmd, cwd=main_dir, capture_output=True,
text=True, errors="replace", timeout=120)
log = proc.stdout or proc.stderr or ""
except FileNotFoundError:
return jsonify(error="pdflatex is not installed on the API server.")
except subprocess.TimeoutExpired:
return jsonify(error="Compilation timed out after 120s.")
pdf_path = os.path.join(main_dir, os.path.splitext(main_name)[0] + ".pdf")
if not os.path.isfile(pdf_path):
return jsonify(error=log[-4000:] or "pdflatex produced no PDF")
with open(pdf_path, "rb") as fh:
pdf_b64 = base64.b64encode(fh.read()).decode("ascii")
return jsonify(success=True, pdf="data:application/pdf;base64," + pdf_b64)
if __name__ == "__main__":
app.run(port=8080)
Run it, then put http://localhost:8080/compile in the Compiler API field and press Test.
6. Reference implementation — Node / Express
Same contract, single pdflatex pass. Needs npm i express.
const express = require("express");
const { execFile } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const app = express();
app.use(express.json({ limit: "50mb" }));
app.use((req, res, next) => {
res.set("Access-Control-Allow-Origin", req.headers.origin || "*");
res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
app.post("/compile", (req, res) => {
const { main = "main.tex", files = [] } = req.body || {};
const work = fs.mkdtempSync(path.join(os.tmpdir(), "gitlatex-"));
for (const f of files) {
const parts = String(f.path || "").replace(/\\/g, "/").split("/").filter(Boolean);
if (!parts.length || parts.includes("..")) continue;
const dest = path.join(work, ...parts);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, f.base64 != null
? Buffer.from(f.base64, "base64")
: String(f.content || ""));
}
const mainParts = String(main).replace(/\\/g, "/").split("/").filter(Boolean);
const mainDir = path.dirname(path.join(work, ...mainParts));
const mainName = mainParts[mainParts.length - 1];
execFile("pdflatex", ["-interaction=nonstopmode", "-halt-on-error", mainName],
{ cwd: mainDir, timeout: 120000 }, (err, stdout, stderr) => {
const pdf = path.join(mainDir, mainName.replace(/\.tex$/i, ".pdf"));
if (!fs.existsSync(pdf)) {
// err is set when pdflatex is missing from PATH or the timeout fires.
const log = stdout || stderr || (err && err.message) || "pdflatex produced no PDF";
return res.json({ error: log.slice(-4000) });
}
res.json({
success: true,
pdf: "data:application/pdf;base64," + fs.readFileSync(pdf).toString("base64")
});
});
});
app.listen(8080, () => console.log("Compiler API on http://localhost:8080/compile"));
7. Testing it
The Test button in Settings sends a minimal payload — it does not touch your repository:
{
"main": "test.tex",
"files": [
{ "path": "test.tex",
"content": "\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}" }
]
}
It passes when your API replies with a 2xx status and success: true. Note that Test reads the URL currently typed in the field, so you can try one out before saving it.
8. Things to get right
- Never write files outside your work directory. Paths come from a client. Reject any segment equal to
..and any absolute path, as both samples above do. - Use a fresh directory per request. Concurrent compiles sharing one folder will overwrite each other's
.auxfiles. - Run
pdflatextwice if the document uses\ref,\citeor a table of contents. Add abibtexorbiberpass between runs if there is a.bib. - Always cap the run. A runaway macro will otherwise pin a CPU forever;
-interaction=nonstopmodeplus a timeout handles it. - Sandbox anything public. LaTeX can read and write files and, with
-shell-escape, run commands. Never enable shell escape on an API others can reach, and prefer a container. - Serve it over HTTPS if it is not on localhost — the API key is sent as a bearer token on every compile.