diff --git a/Dockerfile b/Dockerfile index 07b3357..3db4426 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 85c4a24..0f8264b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..68cb1e8 --- /dev/null +++ b/docker-compose.yaml @@ -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 diff --git a/main.py b/main.py index 25dfdea..c39f8cd 100644 --- a/main.py +++ b/main.py @@ -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__": diff --git a/public/styles.css b/public/styles.css index b99a2b9..5cc02ef 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } } } diff --git a/templates/edit.html b/templates/edit.html index 84748a1..b8d50e7 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -3,23 +3,29 @@
-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.