initial prototype
commit
763ec9e384
@ -0,0 +1,13 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Generated directories
|
||||
runs/
|
||||
@ -0,0 +1 @@
|
||||
3.14
|
||||
@ -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 |
@ -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>
|
||||
Loading…
Reference in New Issue