initial prototype

main
Antonio De Lucreziis 5 months ago
commit 763ec9e384

13
.gitignore vendored

@ -0,0 +1,13 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Generated directories
runs/

@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
UV_LINK_MODE=copy \
UV_NO_SYNC_PROGRESS=1 \
UV_COMPILE_BYTECODE=1 \
PORT=8000
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl ca-certificates \
imagemagick \
ghostscript \
fonts-dejavu \
pdf2svg \
texlive \
texlive-latex-extra \
texlive-pictures \
texlive-science \
&& rm -rf /var/lib/apt/lists/*
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Change the working directory to the `app` directory
WORKDIR /app
# Install dependencies first (better cache hit)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock,readwrite \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml,readwrite \
uv sync --locked --no-install-project
# Copy the project into the image
COPY . /app
# Sync the project
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
ENV PATH="/app/.venv/bin:${PATH}"
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

@ -0,0 +1,116 @@
# 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`).
## Prerequisites
- System tools:
- `pdflatex` (TeX Live)
- `magick` (ImageMagick)
## Install (uv)
From this folder:
```bash
uv sync
```
## Configure LLM
This project uses LiteLLM with Google Gemini models.
Set credentials (common case):
```bash
export GOOGLE_API_KEY="..."
```
Defaults:
- `LLM_MODEL=gemini/gemini-3-flash-preview` (image → TikZ)
- `EDIT_MODEL=gemini/gemini-3-flash-preview` (text edits on LaTeX)
Override if you want:
```bash
export LLM_MODEL="gemini/gemini-3-flash-preview"
export EDIT_MODEL="gemini/gemini-3-flash-preview"
```
## Run
### Option A: run via Python entrypoint
```bash
uv run python main.py
```
### Option B: run via uvicorn (recommended for dev)
```bash
uv run uvicorn main:app --reload
```
Open:
- http://127.0.0.1:8000
## Docker
Single-stage `python:3.12-slim` + `uv` with cached dependency layer. BuildKit is required for cache mounts.
Build locally:
```bash
docker build -t latex-diagram-to-tikz .
```
### Run with Docker
```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 BASE_PATH="/" \
-v $(pwd)/runs:/app/runs \
latex-diagram-to-tikz \
uv run uvicorn main:app --host 0.0.0.0 --port 8000
```
### Run with Docker Compose
Run with Compose (persists `runs/` to the host):
```yaml
services:
app:
build: .
ports:
- "8000:8000"
environment:
GOOGLE_API_KEY: "your-google-api-key"
LLM_MODELS: "gemini/gemini-3-flash-preview"
EDIT_MODELS: "gemini/gemini-3-flash-preview"
BASE_PATH: "/"
volumes:
- ./runs:/app/runs
command: uv run uvicorn main:app --host 0.0.0.0 --port 8000
```
Then start it:
```bash
docker compose up --build
```
## Outputs
Each request creates a folder under `runs/<run_id>/` containing:
- `original.*` (your upload)
- `diagram.tex`
- `diagram.pdf` (if LaTeX compilation succeeded)
- `preview.png` (if ImageMagick rendering succeeded)
- `run.log.txt` (all logs: LLM + pdflatex + magick)

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

1078
main.py

File diff suppressed because it is too large Load Diff

@ -0,0 +1,365 @@
@layer reset, base, components, pages;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
}
img {
max-width: 100%;
height: auto;
display: block;
}
textarea {
font: inherit;
}
}
@layer base {
:root {
color-scheme: light dark;
--border: 1px solid rgba(127, 127, 127, 0.35);
--border-danger: 1px solid rgba(255, 0, 0, 0.35);
--radius-lg: 0.75rem;
--radius-md: 0.625rem;
--pad-1: 0.5rem;
--pad-2: 0.75rem;
--pad-3: 1rem;
--pad-4: 1.5rem;
--text-dim: 0.78;
--btn-bg: #f2f3f5;
--btn-bg-hover: #e7e8ec;
--btn-bg-active: #d9dbe0;
--btn-border: #d0d4db;
--btn-text: #20242c;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--btn-bg: #2d3138;
--btn-bg-hover: #343944;
--btn-bg-active: #2a2e35;
--btn-border: #3c414c;
--btn-text: #e8ecf2;
}
}
body {
font-family: var(--sans);
min-height: 100vh;
}
code {
font-family: var(--mono);
font-size: 0.95em;
}
}
@layer components {
.wrap {
max-width: 54rem;
margin-inline: auto;
padding: var(--pad-4);
}
.label {
font-size: 0.75rem;
opacity: var(--text-dim);
font-weight: 600;
}
.hint {
font-size: 0.75rem;
opacity: var(--text-dim);
}
.card {
border: var(--border);
border-radius: var(--radius-lg);
padding: var(--pad-3);
}
.field {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.4rem;
label {
font-size: 0.875rem;
opacity: 0.9;
}
input[type="file"],
input[type="text"] {
width: 100%;
}
input[type="text"] {
padding: var(--pad-2);
border-radius: var(--radius-lg);
border: var(--border);
background: transparent;
color: inherit;
}
textarea {
width: 100%;
min-height: 6rem;
padding: var(--pad-2);
border-radius: var(--radius-lg);
border: var(--border);
background: transparent;
color: inherit;
resize: vertical;
}
}
button,
a.button,
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 0.875rem;
border-radius: var(--radius-md);
border: 1px solid var(--btn-border);
background: var(--btn-bg);
cursor: pointer;
text-decoration: none;
color: var(--btn-text);
gap: 0.4rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), inset 0 -1px 0 rgba(0, 0, 0, 0.125);
&:hover {
background: var(--btn-bg-hover);
}
&:active {
background: var(--btn-bg-active);
}
.icon {
font-size: 0.9em;
line-height: 1;
}
}
.error {
border: var(--border-danger);
border-radius: var(--radius-lg);
padding: var(--pad-3);
white-space: pre-wrap;
}
}
@layer pages {
/* Index page */
.page-index {
.wrap {
display: grid;
gap: 1rem;
}
h1 {
font-size: 1.25rem;
margin: 0;
}
p {
margin: 0;
opacity: 0.88;
line-height: 1.45;
}
form {
display: grid;
gap: 0.9rem;
}
.preview {
display: none;
margin-top: 0.75rem;
padding: var(--pad-2);
border-radius: var(--radius-lg);
border: var(--border);
img {
border-radius: var(--radius-md);
}
.meta {
margin-top: 0.5rem;
font-size: 0.75rem;
opacity: var(--text-dim);
word-break: break-word;
}
}
}
/* Result page */
.page-result {
display: grid;
grid-template-rows: auto 1fr;
.topbar {
display: flex;
gap: 0.75rem;
align-items: center;
padding: var(--pad-2) var(--pad-3);
border-bottom: var(--border);
a {
text-decoration: none;
}
}
.container {
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
}
.pane {
min-width: 0;
padding: var(--pad-3);
}
.left {
border-right: var(--border);
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
}
.edit-form {
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
.prompt-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
input[type="text"] {
flex: 1;
min-width: 0;
}
.btn {
white-space: nowrap;
}
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
textarea {
min-height: 10rem;
}
}
/* Fallback: ensure layout even without CSS nesting support */
.edit-form .prompt-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.edit-form .prompt-row input[type="text"] {
flex: 1;
min-width: 0;
}
.edit-form .prompt-row .btn {
white-space: nowrap;
}
.right {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 0.75rem;
}
textarea {
width: 100%;
height: 100%;
resize: none;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.45;
padding: var(--pad-2);
border-radius: var(--radius-lg);
border: var(--border);
white-space: pre;
overflow: auto;
}
.preview {
min-height: 15rem;
display: grid;
place-items: center;
border-radius: var(--radius-lg);
border: var(--border);
overflow: auto;
}
.downloads {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.error {
font-family: var(--mono);
font-size: 0.85rem;
}
@media (max-width: 60rem) {
.container {
grid-template-columns: 1fr;
}
.left {
border-right: none;
border-bottom: var(--border);
}
}
@media (max-width: 40rem) {
.topbar {
flex-wrap: wrap;
gap: 0.5rem;
padding: var(--pad-2) var(--pad-2);
}
.topbar .label {
display: none;
}
}
}
}

@ -0,0 +1,14 @@
[project]
name = "latex-diagram-to-tikz"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.110",
"jinja2>=3.1",
"litellm>=1.60",
"pillow>=12.0.0",
"python-multipart>=0.0.9",
"uvicorn[standard]>=0.30",
]

@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Result — Diagram → TikZ</title>
<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">
<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>
<div class="container">
<div class="pane left">
<div class="label">Standalone LaTeX/TikZ</div>
<form action="{{ base_path }}/{{ run_id }}" method="post" class="edit-form">
<div class="field">
<label for="instructions">Edit instructions</label>
<div class="prompt-row">
<input
id="instructions"
name="instructions"
autocomplete="off"
type="text"
placeholder="e.g. move labels closer, align boxes, add arrows"
/>
<button class="btn" type="submit">Apply edits</button>
</div>
<div class="hint">
Applies edits to the LaTeX only, then compiles. {% if last_edit_model %}Last edit model: {{
last_edit_model }}.{% endif %}
<br />Edit quota: {{ edit_remaining }}/{{ edit_limit }} left today.
</div>
</div>
<div class="field">
<label for="history-select">History</label>
<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>
<div class="field">
<label for="latex-source">LaTeX Source</label>
<textarea id="latex-source" name="latex" spellcheck="false" wrap="off">{{ tex }}</textarea>
</div>
<div class="actions">
<button class="btn" type="submit" formaction="{{ base_path }}/{{ run_id }}/compile">
<i class="fa-solid fa-play icon" aria-hidden="true"></i>
<span>Compile</span>
</button>
</div>
</form>
</div>
<div class="pane right">
<div class="label">Rendered preview</div>
<div class="preview">
{% if png_url %}
<img src="{{ png_url }}" alt="Rendered TikZ preview" />
{% else %}
<div class="label">No preview available.</div>
{% endif %}
</div>
<div class="downloads">
<a class="button" href="{{ download_original_url }}">Download original image</a>
<a class="button" href="{{ download_tex_url }}">Download .tex</a>
<a class="button" href="{{ download_pdf_url }}">Download .pdf</a>
<a class="button" href="{{ download_svg_url }}">Download .svg</a>
<a class="button" href="{{ download_png_url }}">Download .png</a>
</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
</div>
</div>
</body>
<script>
;(() => {
const basePath = "{{ base_path }}"
const runId = "{{ run_id }}"
const historySelect = document.getElementById("history-select")
const latexArea = document.getElementById("latex-source")
const currentTex = latexArea ? latexArea.value : ""
let historyEntries = []
const formatTs = ts => {
if (!ts) return "unknown time"
const d = new Date(ts * 1000)
return d.toISOString().replace("T", " ").slice(0, 19)
}
const renderOptions = () => {
if (!historySelect) return
const frag = document.createDocumentFragment()
const current = document.createElement("option")
current.value = "current"
current.textContent = "Current"
frag.appendChild(current)
historyEntries.forEach((entry, idx) => {
const opt = document.createElement("option")
opt.value = String(idx)
const statusLabel = entry.status === "error" ? " (error)" : ""
opt.textContent = `${entry.action || "edit"} @ ${formatTs(entry.ts)}${statusLabel}`
frag.appendChild(opt)
})
historySelect.innerHTML = ""
historySelect.appendChild(frag)
}
const loadHistory = async () => {
if (!historySelect) return
try {
const resp = await fetch(`${basePath}/${runId}/history`)
if (!resp.ok) return
const data = await resp.json()
historyEntries = Array.isArray(data.entries) ? data.entries : []
renderOptions()
} catch (err) {
console.error("history.load.failed", err)
}
}
if (historySelect && latexArea) {
historySelect.addEventListener("change", () => {
const value = historySelect.value
if (value === "current") {
latexArea.value = currentTex
return
}
const idx = Number(value)
if (Number.isNaN(idx) || idx < 0 || idx >= historyEntries.length) {
return
}
const entry = historyEntries[idx]
latexArea.value = entry?.latex || ""
})
loadHistory()
}
})()
</script>
</html>

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Diagram → TikZ</title>
<link rel="stylesheet" href="{{ base_path }}/public/styles.css" />
</head>
<body class="page page-index">
<div class="wrap">
<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.
</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form class="card" action="{{ base_path }}/convert" method="post" enctype="multipart/form-data">
<div class="field">
<label for="image">Diagram image</label>
<input id="image" name="image" type="file" accept="image/*" required />
<div class="hint">
Accepted: any format your browser can upload (PNG/JPG recommended). You can also paste an image
from the clipboard (Ctrl/Cmd+V).
</div>
<div class="hint">Daily convert quota: {{ convert_remaining }}/{{ convert_limit }} left today.</div>
<div id="preview" class="preview" aria-live="polite">
<img id="preview-img" alt="Selected upload preview" />
<div id="preview-meta" class="meta"></div>
</div>
</div>
<div class="field">
<label for="notes">Notes for the model (optional)</label>
<textarea
id="notes"
name="notes"
rows="3"
placeholder="Constraints, naming, style, etc."
></textarea>
<div class="hint">These notes are sent along with the image to guide the conversion.</div>
</div>
<div id="submit-status" class="hint" aria-live="polite" style="display: none"></div>
<button id="convert-btn" class="btn" type="submit">Convert</button>
</form>
</div>
<script>
;(function () {
const form = document.querySelector("form")
const submitStatus = document.getElementById("submit-status")
const convertBtn = document.getElementById("convert-btn")
const input = document.getElementById("image")
const preview = document.getElementById("preview")
const img = document.getElementById("preview-img")
const meta = document.getElementById("preview-meta")
let lastUrl = null
function clearPreview() {
if (lastUrl) URL.revokeObjectURL(lastUrl)
lastUrl = null
img.removeAttribute("src")
meta.textContent = ""
preview.style.display = "none"
}
function showFile(file) {
if (!file || !file.type || !file.type.startsWith("image/")) {
clearPreview()
return
}
if (lastUrl) URL.revokeObjectURL(lastUrl)
lastUrl = URL.createObjectURL(file)
img.src = lastUrl
meta.textContent = `${file.name || "pasted-image"} — ${(file.size / 1024).toFixed(1)} KB`
preview.style.display = "block"
}
function setInputFile(file) {
try {
const dt = new DataTransfer()
dt.items.add(file)
input.files = dt.files
input.dispatchEvent(new Event("change", { bubbles: true }))
} catch {
// If the browser blocks programmatic assignment to input.files,
// still show preview so the user can manually re-select if needed.
showFile(file)
}
}
input.addEventListener("change", () => {
const file = input.files && input.files[0]
if (!file) {
clearPreview()
return
}
showFile(file)
})
window.addEventListener("paste", e => {
const cd = e.clipboardData
if (!cd || !cd.items) return
for (const item of cd.items) {
if (item.kind === "file" && item.type && item.type.startsWith("image/")) {
const file = item.getAsFile()
if (!file) continue
e.preventDefault()
setInputFile(file)
return
}
}
})
if (form && convertBtn && submitStatus) {
form.addEventListener("submit", () => {
convertBtn.disabled = true
convertBtn.textContent = "Converting…"
submitStatus.textContent = "Be patient — this may take up to a minute."
submitStatus.style.display = "block"
})
}
})()
</script>
</body>
</html>

1179
uv.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save