You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
185 lines
7.7 KiB
HTML
185 lines
7.7 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Diagram → TikZ</title>
|
|
<meta property="og:title" content="Diagram → TikZ" />
|
|
<meta
|
|
property="og:description"
|
|
content="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."
|
|
/>
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:url" content="{{ base_path }}/" />
|
|
<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>
|
|
<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" class="sr-only" name="image" type="file" accept="image/*" required />
|
|
<label for="image" id="dropzone" class="dropzone"
|
|
>Click here to upload an image (or drag & drop / paste)</label
|
|
>
|
|
<div class="hint">Accepted: any format your browser can upload (PNG/JPG recommended).</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 dropzone = document.getElementById("dropzone")
|
|
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)
|
|
})
|
|
|
|
// Drag and drop support on label dropzone
|
|
dropzone.addEventListener("dragenter", e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
dropzone.classList.add("drag-over")
|
|
})
|
|
|
|
dropzone.addEventListener("dragleave", e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
dropzone.classList.remove("drag-over")
|
|
})
|
|
|
|
dropzone.addEventListener("dragover", e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
})
|
|
|
|
dropzone.addEventListener("drop", e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
dropzone.classList.remove("drag-over")
|
|
|
|
const files = e.dataTransfer.files
|
|
if (!files || files.length === 0) return
|
|
|
|
for (const file of files) {
|
|
if (file.type && file.type.startsWith("image/")) {
|
|
setInputFile(file)
|
|
return
|
|
}
|
|
}
|
|
})
|
|
|
|
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>
|