feat: enhance security features and improve user interface in LaTeX diagram tool

main
Antonio De Lucreziis 7 months ago
parent fcc9b086db
commit 7ee022b565

@ -34,6 +34,16 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml,readwrite \
uv sync --locked --no-install-project
# Add latex safety features
# Hardening TeXLive configuration for security
# 1. Disable shell_escape (prevents \write18 execution of arbitrary shell commands)
# 2. Set openout/openin to paranoid (prevents reading/writing files outside working dir like /etc/passwd)
# 3. Disable parse_first_line (prevents passing arguments via source file comments)
RUN echo "shell_escape = f" >> /etc/texmf/web2c/texmf.cnf && \
echo "openout_any = p" >> /etc/texmf/web2c/texmf.cnf && \
echo "openin_any = p" >> /etc/texmf/web2c/texmf.cnf && \
echo "parse_first_line = f" >> /etc/texmf/web2c/texmf.cnf
# Copy the project into the image
COPY . /app

@ -1,13 +1,17 @@
# latex-diagram-to-tikz
Small HTTP server that converts a hand-drawn diagram image into standalone LaTeX/TikZ using an LLM (via LiteLLM), then compiles it with `pdflatex` and renders a PNG preview via ImageMagick (`magick`).
Small HTTP server that converts a hand-drawn diagram image into standalone LaTeX/TikZ using an LLM, then compiles it with `pdflatex` and renders a PNG preview via ImageMagick (`magick`).
## Prerequisites
- System tools:
- `pdflatex` (TeX Live)
- `magick` (ImageMagick)
- `pdf2svg`
## Install (uv)
From this folder:
@ -26,16 +30,16 @@ Set credentials (common case):
export GOOGLE_API_KEY="..."
```
Defaults:
Defaults (comma-separated fallback order):
- `LLM_MODEL=gemini/gemini-3-flash-preview` (image → TikZ)
- `EDIT_MODEL=gemini/gemini-3-flash-preview` (text edits on LaTeX)
- `LLM_MODELS=gemini/gemini-3-pro-preview,gemini/gemini-3-flash-preview,gemini/gemini-flash-latest`
- `EDIT_MODELS=gemini/gemini-3-flash-preview,gemini/gemini-flash-latest`
Override if you want:
```bash
export LLM_MODEL="gemini/gemini-3-flash-preview"
export EDIT_MODEL="gemini/gemini-3-flash-preview"
export LLM_MODELS="gemini/gemini-3-flash-preview"
export EDIT_MODELS="gemini/gemini-3-flash-preview"
```
## Run
@ -71,8 +75,8 @@ docker build -t latex-diagram-to-tikz .
```bash
docker run -it --rm -p 8000:8000 \
-e GOOGLE_API_KEY="your-google-api-key" \
-e LLM_MODELS="gemini/gemini-3-flash-preview" \
-e EDIT_MODELS="gemini/gemini-3-flash-preview" \
-e LLM_MODELS="gemini/gemini-3-pro-preview,gemini/gemini-3-flash-preview,gemini/gemini-flash-latest" \
-e EDIT_MODELS="gemini/gemini-3-flash-preview,gemini/gemini-flash-latest" \
-e BASE_PATH="/" \
-v $(pwd)/runs:/app/runs \
latex-diagram-to-tikz \
@ -91,9 +95,16 @@ services:
- "8000:8000"
environment:
GOOGLE_API_KEY: "your-google-api-key"
LLM_MODELS: "gemini/gemini-3-flash-preview"
EDIT_MODELS: "gemini/gemini-3-flash-preview"
LLM_MODELS: "gemini/gemini-3-pro-preview,gemini/gemini-3-flash-preview,gemini/gemini-flash-latest"
EDIT_MODELS: "gemini/gemini-3-flash-preview,gemini/gemini-flash-latest"
BASE_PATH: "/"
LOG_LEVEL: "INFO"
CONVERT_DAILY_LIMIT: "5"
EDIT_DAILY_LIMIT: "10"
PDFLATEX_TIMEOUT_SECONDS: "10"
HOST: "0.0.0.0"
PORT: "8000"
RELOAD: "0"
volumes:
- ./runs:/app/runs
command: uv run uvicorn main:app --host 0.0.0.0 --port 8000

@ -0,0 +1,17 @@
services:
app:
build: .
ports:
- "8000:8000"
environment:
GOOGLE_API_KEY: "your-google-api-key"
LLM_MODELS: "gemini/gemini-3-flash-preview,gemini/gemini-flash-latest"
EDIT_MODELS: "gemini/gemini-3-flash-preview,gemini/gemini-flash-latest"
BASE_PATH: "/"
CONVERT_DAILY_LIMIT: "5"
EDIT_DAILY_LIMIT: "10"
PDFLATEX_TIMEOUT_SECONDS: "10"
RELOAD: "0"
volumes:
- ./runs:/app/runs
command: uv run uvicorn main:app --host 0.0.0.0 --port 8000

@ -5,6 +5,7 @@ import logging
import mimetypes
import os
import re
import shutil
import subprocess
import time
import uuid
@ -36,6 +37,10 @@ def _normalize_base_path(value: str) -> str:
BASE_PATH = _normalize_base_path(os.getenv("BASE_PATH", ""))
CONVERT_DAILY_LIMIT = int(os.getenv("CONVERT_DAILY_LIMIT", "5"))
EDIT_DAILY_LIMIT = int(os.getenv("EDIT_DAILY_LIMIT", "10"))
PDFLATEX_TIMEOUT_SECONDS = int(os.getenv("PDFLATEX_TIMEOUT_SECONDS", "10"))
def _parse_model_list(env_value: str | None, default: list[str]) -> list[str]:
if not env_value:
@ -53,6 +58,8 @@ DEFAULT_MODELS = _parse_model_list(
"gemini/gemini-flash-latest",
],
)
EDIT_MODELS = _parse_model_list(
os.getenv("EDIT_MODELS"),
[
@ -61,9 +68,6 @@ EDIT_MODELS = _parse_model_list(
],
)
CONVERT_DAILY_LIMIT = 5
EDIT_DAILY_LIMIT = 10
class RateLimitEntry(TypedDict, total=False):
day: str
@ -417,20 +421,29 @@ def _run_cmd(
def _compile_pdflatex(
run_dir: Path, tex_filename: str, run_logger: RunLogger, section_title: str
) -> Optional[Path]:
cmd = [
"pdflatex",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-output-directory",
str(run_dir),
str(run_dir / tex_filename),
]
code, out = _run_cmd(cmd, cwd=run_dir, timeout_s=180, py_logger=run_logger.py)
run_logger.section(section_title, out)
code, out = _run_cmd(
[
"pdflatex",
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-output-directory",
str(run_dir),
str(run_dir / tex_filename),
],
cwd=run_dir,
timeout_s=PDFLATEX_TIMEOUT_SECONDS,
py_logger=run_logger.py,
)
if code == 0:
run_logger.section(section_title, "Compilation succeeded.")
else:
run_logger.section(section_title, out)
pdf_path = run_dir / Path(tex_filename).with_suffix(".pdf").name
if code == 0 and pdf_path.exists():
return pdf_path
return None
@ -453,7 +466,7 @@ def _render_png_with_magick(
"off",
str(png_path),
]
code, out = _run_cmd(cmd, cwd=run_dir, timeout_s=180, py_logger=run_logger.py)
code, out = _run_cmd(cmd, cwd=run_dir, timeout_s=5, py_logger=run_logger.py)
run_logger.section(section_title, out)
if code == 0 and png_path.exists():
return png_path
@ -505,7 +518,7 @@ def _render_svg_with_pdf2svg(
) -> Optional[Path]:
svg_path = run_dir / svg_name
cmd = ["pdf2svg", str(pdf_path), str(svg_path), "1"]
code, out = _run_cmd(cmd, cwd=run_dir, timeout_s=180, py_logger=run_logger.py)
code, out = _run_cmd(cmd, cwd=run_dir, timeout_s=5, py_logger=run_logger.py)
run_logger.section(section_title, out)
if code == 0 and svg_path.exists():
return svg_path
@ -1188,6 +1201,26 @@ async def get_history(request: Request, run_id: str):
return JSONResponse({"entries": safe_entries})
@app.post("/d/{run_id}/delete", response_class=HTMLResponse)
async def delete_run(request: Request, run_id: str):
run_dir = _run_dir_for_id(run_id)
if run_dir is None:
return RedirectResponse(url=f"{BASE_PATH}/?error=Invalid%20run%20id", status_code=303)
try:
resolved_runs_dir = RUNS_DIR.resolve()
resolved_run_dir = run_dir.resolve()
if resolved_runs_dir not in resolved_run_dir.parents:
logger.warning("delete.invalid_path run_id=%s path=%s", run_id, str(run_dir))
return HTMLResponse("Invalid run directory", status_code=400)
shutil.rmtree(resolved_run_dir)
logger.info("delete.ok run_id=%s", run_id)
except Exception:
logger.exception("delete.error run_id=%s", run_id)
return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}?error=Delete%20failed", status_code=303)
return RedirectResponse(url=f"{BASE_PATH}/", status_code=303)
@app.get("/runs/{run_id}/{filename}")
async def get_run_file(run_id: str, filename: str):
path = _safe_join_run_file(run_id, filename)
@ -1195,7 +1228,14 @@ async def get_run_file(run_id: str, filename: str):
logger.info("download.not_found run_id=%s filename=%s", run_id, filename)
return HTMLResponse("Not found", status_code=404)
logger.info("download.ok run_id=%s filename=%s", run_id, filename)
return FileResponse(path)
return FileResponse(
path,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
if __name__ == "__main__":

@ -53,8 +53,8 @@
--pad-md: 0.75rem;
--pad-lg: 1.5rem;
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Courier New", monospace;
--checkerboard-color: light-dark(rgb(from #000 r g b / 0.2), rgb(from #fff r g b / 0.2));
}
@ -95,7 +95,6 @@
h3 {
margin: 0;
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.02em;
}
}
@ -123,16 +122,21 @@
align-items: center;
justify-content: center;
padding: var(--pad-sm) var(--pad-md);
background: var(--c-primary);
color: oklch(from var(--c-primary) calc(1 - l) c h);
border: var(--border);
box-shadow: var(--shadow-sm);
--button-accent: var(--c-primary);
--button-accent-border: oklch(from var(--button-accent) calc(1.1 - l) c h);
background: var(--button-accent);
color: oklch(from var(--button-accent) calc(1 - l) c h);
border: var(--border-width) solid var(--button-accent-border);
box-shadow: 2px 2px 0 0 var(--button-accent-border);
font-weight: 800;
text-decoration: none;
cursor: pointer;
transition: all 0.1s;
gap: 0.5rem;
font-size: 0.85rem;
font-size: 14px;
text-transform: uppercase;
&:hover {
@ -158,6 +162,11 @@
font-size: 0.9em;
line-height: 1;
}
&.danger {
--button-accent: var(--c-danger);
--button-accent-border: oklch(from var(--button-accent) calc(1.1 - l) c h);
}
}
/* Variant buttons */
@ -169,7 +178,7 @@
}
.btn-danger {
background: var(--c-danger);
color: oklch(from var(--c-danger) calc(1 - l) c h);
color: oklch(from var(--c-danger) calc(1.1 - l) c h);
}
input[type="text"],
@ -182,7 +191,7 @@
background: var(--c-surface);
border-radius: 0;
outline: none;
font-size: 0.9rem;
font-size: 14px;
transition: box-shadow 0.1s;
&:focus {
@ -193,28 +202,32 @@
textarea {
resize: vertical;
min-height: 4rem;
}
.label {
--label-accent: var(--c-accent);
--label-accent-border: oklch(from var(--label-accent) calc(1.3 - l) c h);
font-weight: 800;
text-transform: uppercase;
font-size: 0.7rem;
/* text-transform: uppercase; */
font-size: 13px;
margin-bottom: 0.25rem;
display: block;
background: var(--c-accent);
color: oklch(from var(--c-accent) calc(1 - l) c h);
background: var(--label-accent);
color: oklch(from var(--label-accent) calc(1.1 - l) c h);
display: inline-block;
padding: 0.1rem 0.3rem;
border: var(--border);
box-shadow: 2px 2px 0 0 var(--border-color);
border: var(--border-width) solid var(--label-accent-border);
box-shadow: 2px 2px 0 0 var(--label-accent-border);
}
.hint {
font-size: 0.75rem;
font-weight: 600;
font-size: 13px;
font-weight: 500;
opacity: 0.7;
margin-top: 0.25rem;
display: grid;
align-content: center;
}
.error {
@ -225,6 +238,7 @@
font-weight: bold;
box-shadow: var(--shadow);
white-space: pre-wrap;
margin-top: var(--pad-sm);
}
.field {
@ -232,11 +246,39 @@
gap: 0.25rem;
label {
font-size: 0.875rem;
font-size: 14px;
font-weight: bold;
}
}
.field-group {
display: grid;
gap: 0.25rem;
}
.field-group-title {
font-size: 14px;
font-weight: bold;
margin: 0;
text-transform: none;
letter-spacing: normal;
}
.pane-title {
font-weight: 800;
text-transform: uppercase;
font-size: 14px;
margin: 0;
display: block;
background: var(--c-accent);
color: oklch(from var(--c-accent) calc(1.1 - l) c h);
display: inline-block;
padding: 0.1rem 0.3rem;
border: var(--border-width) solid oklch(from var(--c-accent) calc(1.2 - l) c h);
box-shadow: 2px 2px 0 0 oklch(from var(--c-accent) calc(1.2 - l) c h);
}
/* Visually hidden but accessible (for native input) */
.sr-only {
position: absolute;
@ -284,12 +326,14 @@
h1 {
background: var(--c-secondary);
color: oklch(from var(--c-secondary) calc(1.1 - l) c h);
border: var(--border);
box-shadow: var(--shadow);
padding: var(--pad-sm);
text-align: center;
margin-bottom: var(--pad-md);
font-size: 1.5rem;
font-size: 24px;
}
p {
@ -297,7 +341,7 @@
border: var(--border);
padding: var(--pad-sm);
margin-bottom: var(--pad-md);
font-size: 0.9rem;
font-size: 14px;
font-weight: 500;
}
@ -315,15 +359,17 @@
.meta {
margin-top: var(--pad-xs);
font-family: var(--font-mono);
font-size: 0.75rem;
font-size: 12px;
border-top: var(--border);
padding-top: var(--pad-xs);
}
}
}
/* Result page */
.page-result {
/* Edit page */
.page-edit {
user-select: none;
display: grid;
grid-template-rows: auto 1fr;
min-height: 100vh;
@ -350,8 +396,7 @@
.label {
margin: 0;
box-shadow: none;
background: var(--c-secondary);
color: oklch(from var(--c-secondary) calc(1 - l) c h);
--label-accent: var(--c-secondary);
}
}
@ -389,7 +434,8 @@
height: 100%;
min-height: 0;
.field {
.field,
.field-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
@ -414,13 +460,14 @@
textarea {
flex: 1;
min-height: 0;
font-family: var(--font-mono);
font-size: 0.8rem;
font-size: 12px;
line-height: 1.5;
white-space: pre;
border: var(--border);
box-shadow: inset 2px 2px 0 0 rgba(0, 0, 0, 0.05);
min-height: 20rem;
}
.actions {
@ -467,14 +514,10 @@
gap: var(--pad-sm);
.button {
font-size: 0.75rem;
--button-accent: var(--c-surface);
--button-accent-border: var(--border-color);
padding: var(--pad-xs) var(--pad-sm);
background: var(--c-surface);
color: oklch(from var(--c-surface) calc(1 - l) c h);
&:hover {
background: var(--c-secondary);
color: oklch(from var(--c-secondary) calc(1 - l) c h);
}
}
}
@ -489,9 +532,8 @@
}
.topbar {
.label {
display: none;
}
flex-direction: column;
align-items: start;
}
}
}

@ -3,23 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Result — Diagram → TikZ</title>
<title>{{ run_id }} — Diagram → TikZ</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700;800&family=JetBrains+Mono:wght@400;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="{{ base_path }}/public/styles.css" />
</head>
<body class="page page-result">
<body class="page page-edit">
<div class="topbar">
<a class="no-wrap" href="{{ base_path }}/">← New upload</a>
<div class="label run-id" title="{{ run_id }}">Run: {{ run_id }}</div>
<div class="label no-wrap">Model: {{ convert_model }}</div>
<div class="label run-id" title="{{ run_id }}">DIAGRAM: {{ run_id }}</div>
<div class="label no-wrap">MODEL: {{ convert_model }}</div>
</div>
<div class="container">
<div class="pane left">
<div class="label">Standalone LaTeX/TikZ</div>
<h2 class="pane-title">Standalone LaTeX/TikZ</h2>
<form action="{{ base_path }}/d/{{ run_id }}" method="post" class="edit-form">
<div class="field">
<label for="instructions">Edit instructions</label>
<section class="field-group">
<h3 class="field-group-title">Edit instructions</h3>
<div class="prompt-row">
<input
id="instructions"
@ -35,34 +41,46 @@
last_edit_model }}.{% endif %}
<br />Edit quota: {{ edit_remaining }}/{{ edit_limit }} left today.
</div>
</div>
</section>
<div class="field">
<label for="history-select">History</label>
<section class="field-group">
<h3 class="field-group-title">History</h3>
<select id="history-select">
<option value="current" selected>Current</option>
</select>
<div class="hint">Most recent first. Selecting an entry loads its LaTeX into the editor.</div>
</div>
</section>
<div class="field">
<label for="latex-source">LaTeX Source</label>
<section class="field-group">
<h3 class="field-group-title">LaTeX Source</h3>
<textarea id="latex-source" name="latex" spellcheck="false" wrap="off">{{ tex }}</textarea>
</div>
<div class="actions">
<button class="btn" type="submit" formaction="{{ base_path }}/d/{{ run_id }}/compile">
<i class="fa-solid fa-play icon" aria-hidden="true"></i>
<span>Compile</span>
</button>
{% if compile_warning %}
<div class="hint warning" id="compile-warning">{{ compile_warning }}</div>
{% endif %}
</div>
</section>
<section class="field-group actions-group">
<div class="actions">
<button class="btn" type="submit" formaction="{{ base_path }}/d/{{ run_id }}/compile">
<i class="fa-solid fa-play icon" aria-hidden="true"></i>
<span>Compile</span>
</button>
{% if compile_warning %}
<div class="hint warning" id="compile-warning">{{ compile_warning }}</div>
{% endif %}
</div>
</section>
<section class="field-group danger-group">
<h3 class="field-group-title">Settings / Danger</h3>
<div class="actions">
<button class="btn danger" type="submit" formaction="{{ base_path }}/d/{{ run_id }}/delete">
<i class="fa-solid fa-trash-can icon" aria-hidden="true"></i>
<span>Delete Diagram</span>
</button>
</div>
</section>
</form>
</div>
<div class="pane right">
<div class="label">Rendered preview</div>
<h2 class="pane-title">Rendered preview</h2>
<div class="preview">
{% if png_url %}
<img src="{{ png_url }}" alt="Rendered TikZ preview" />
@ -72,23 +90,23 @@
</div>
<div class="downloads">
<a class="button" href="{{ download_original_url }}">
<a class="button" href="{{ download_original_url }}" download>
<i class="fa-solid fa-image icon" aria-hidden="true"></i>
<span>Original Image</span>
</a>
<a class="button" href="{{ download_tex_url }}">
<a class="button" href="{{ download_tex_url }}" download>
<i class="fa-solid fa-file-code icon" aria-hidden="true"></i>
<span>LaTeX (.tex)</span>
</a>
<a class="button" href="{{ download_pdf_url }}">
<a class="button" href="{{ download_pdf_url }}" download>
<i class="fa-solid fa-file-pdf icon" aria-hidden="true"></i>
<span>PDF</span>
</a>
<a class="button" href="{{ download_svg_url }}">
<a class="button" href="{{ download_svg_url }}" download>
<i class="fa-solid fa-vector-square icon" aria-hidden="true"></i>
<span>SVG</span>
</a>
<a class="button" href="{{ download_png_url }}">
<a class="button" href="{{ download_png_url }}" download>
<i class="fa-solid fa-file-image icon" aria-hidden="true"></i>
<span>PNG</span>
</a>
@ -105,12 +123,36 @@
;(() => {
const basePath = "{{ base_path }}"
const runId = "{{ run_id }}"
const editForm = document.querySelector(".edit-form")
const instructionsInput = document.getElementById("instructions")
const historySelect = document.getElementById("history-select")
const latexArea = document.getElementById("latex-source")
const compileWarning = document.getElementById("compile-warning")
const currentTex = latexArea ? latexArea.value : ""
let historyEntries = []
// Validate edit/compile/delete actions.
if (editForm && instructionsInput) {
editForm.addEventListener("submit", e => {
const submitter = e.submitter
const action = (submitter && submitter.getAttribute("formaction")) || ""
const isAlternateAction = !!action
const isDeleteAction = action.endsWith("/delete")
const val = (instructionsInput.value || "").trim()
if (isDeleteAction) {
if (!confirm("Delete this run and all its files?")) {
e.preventDefault()
}
return
}
if (!isAlternateAction && !val) {
e.preventDefault()
}
})
}
const formatTs = ts => {
if (!ts) return "unknown time"
const d = new Date(ts * 1000)

@ -4,11 +4,17 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Diagram → TikZ</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700;800&family=JetBrains+Mono:wght@400;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="{{ base_path }}/public/styles.css" />
</head>
<body class="page page-index">
<div class="wrap">
<h1>Handmade diagram → standalone TikZ</h1>
<h1>Handmade Diagram → Standalone TikZ</h1>
<p>
Upload an image of your diagram. The server sends it to an LLM and returns standalone LaTeX/TikZ code,
then compiles it to PDF and renders a PNG preview.

Loading…
Cancel
Save