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