aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorKyle Javier [kj_sh604]2026-02-16 03:06:31 -0500
committerGitHub2026-02-16 03:06:31 -0500
commit56f77847378fc92fa79b5303ca86ff71b5767c42 (patch)
tree34017664c04c2fecb137685a0bdc65ba5d9884ef /src
parentaf1eeceecae9667f8ea069f57d2baf508a08e8de (diff)
parent1272877be9f9c263273fdd0b2c564ef6bd73afbd (diff)
[merge] feat: NimLang Backend Re-write
Diffstat (limited to 'src')
-rw-r--r--src/.gitignore1
-rw-r--r--src/app.nim422
-rw-r--r--src/app.py166
-rw-r--r--src/requirements.txt1
-rw-r--r--src/templates/index.html15
5 files changed, 431 insertions, 174 deletions
diff --git a/src/.gitignore b/src/.gitignore
index fb97398..7128cad 100644
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -1,5 +1,6 @@
__pycache__/
*.pyc
.venv/
+likha-pdf
generated/
uploads/ \ No newline at end of file
diff --git a/src/app.nim b/src/app.nim
new file mode 100644
index 0000000..930e592
--- /dev/null
+++ b/src/app.nim
@@ -0,0 +1,422 @@
+import std/[asynchttpserver, asyncdispatch, os, osproc, streams, strutils, tables, times, uri, random]
+
+# tiny backend in nimlang, may be stupid, but this was fun
+
+const
+ AllowedImageExtensions = ["png", "jpg", "jpeg", "gif", "webp", "svg"]
+ ValidPaperSizes = ["a4paper", "letterpaper", "legalpaper"]
+ ValidMargins = ["0.75in", "1in", "1.25in", "1.5in"]
+
+const
+ AppName = "likha-pdf"
+
+proc baseDir(): string {.inline.} =
+ getAppDir()
+
+proc generatedDir(): string {.inline.} =
+ baseDir() / "generated"
+
+proc uploadsDir(): string {.inline.} =
+ baseDir() / "uploads"
+
+proc latexTemplatePath(): string {.inline.} =
+ baseDir() / "latex" / "template.tex"
+
+proc templatesDir(): string {.inline.} =
+ baseDir() / "templates"
+
+proc partialsDir(): string {.inline.} =
+ templatesDir() / "partials"
+
+proc staticDir(): string {.inline.} =
+ baseDir() / "static"
+
+type MultipartPart = object
+ name: string
+ filename: string
+ contentType: string
+ content: string
+
+# helpers
+proc htmlEscape(value: string): string =
+ result = value
+ result = result.replace("&", "&")
+ result = result.replace("<", "&lt;")
+ result = result.replace(">", "&gt;")
+ result = result.replace("\"", "&quot;")
+ result = result.replace("'", "&#39;")
+
+proc randomHex(length: int): string =
+ const hexChars = "0123456789abcdef"
+ result = newStringOfCap(length)
+ for _ in 0 ..< length:
+ result.add(hexChars[rand(15)])
+
+proc renderTemplate(filePath: string; replacements: openArray[(string, string)]): string =
+ result = readFile(filePath)
+ for (token, replacement) in replacements:
+ result = result.replace(token, replacement)
+
+proc decodeFormComponent(value: string): string =
+ decodeUrl(value.replace("+", " "))
+
+proc parseUrlEncoded(body: string): Table[string, string] =
+ result = initTable[string, string]()
+ if body.len == 0:
+ return
+
+ for pair in body.split("&"):
+ if pair.len == 0:
+ continue
+ let separator = pair.find('=')
+ if separator < 0:
+ result[decodeFormComponent(pair)] = ""
+ else:
+ let key = decodeFormComponent(pair[0 ..< separator])
+ let value = decodeFormComponent(pair[separator + 1 .. ^1])
+ result[key] = value
+
+# "options" are optional, defaults are forever.
+proc pickOption(value: string; fallback: string; options: openArray[string]): string =
+ for option in options:
+ if option == value:
+ return value
+ fallback
+
+proc sanitizeFilename(filename: string): string =
+ result = newStringOfCap(filename.len)
+ for ch in filename:
+ if (ch >= 'a' and ch <= 'z') or
+ (ch >= 'A' and ch <= 'Z') or
+ (ch >= '0' and ch <= '9') or
+ (ch in {'-', '_', '.'}):
+ result.add(ch)
+ elif ch == ' ':
+ result.add('_')
+
+proc baseFilename(value: string): string =
+ var normalized = value.replace("\\", "/")
+ let index = normalized.rfind('/')
+ if index >= 0 and index < normalized.high:
+ normalized = normalized[index + 1 .. ^1]
+ elif index == normalized.high:
+ normalized = ""
+ normalized
+
+proc isAllowedImage(filename: string): bool =
+ let dot = filename.rfind('.')
+ if dot < 1 or dot == filename.high:
+ return false
+ let extension = filename[dot + 1 .. ^1].toLowerAscii()
+ for allowed in AllowedImageExtensions:
+ if extension == allowed:
+ return true
+ false
+
+proc tailText(value: string; maxLen: int = 1200): string =
+ if value.len <= maxLen:
+ return value
+ value[value.len - maxLen .. ^1]
+
+proc extractBoundary(contentType: string): string =
+ for part in contentType.split(';'):
+ let token = part.strip()
+ if token.toLowerAscii().startsWith("boundary="):
+ return token[9 .. ^1].strip(chars = {'\"', '\''})
+ ""
+
+proc stripTrailingCrlf(value: string): string =
+ result = value
+ if result.len >= 2 and result.endsWith("\r\n"):
+ result.setLen(result.len - 2)
+
+# hand-rolled multipart parsing, yes i am aware that this is "eh"
+proc parseMultipart(body: string; boundary: string): seq[MultipartPart] =
+ let delimiter = "--" & boundary
+ for rawChunk in body.split(delimiter):
+ var chunk = rawChunk
+ if chunk.len == 0:
+ continue
+ if chunk == "--" or chunk == "--\r\n":
+ continue
+ if chunk.startsWith("\r\n"):
+ chunk = chunk[2 .. ^1]
+
+ chunk = stripTrailingCrlf(chunk)
+
+ if chunk.len == 2 and chunk == "--":
+ continue
+
+ let splitIndex = chunk.find("\r\n\r\n")
+ if splitIndex < 0:
+ continue
+
+ let headerBlock = chunk[0 ..< splitIndex]
+ var content = chunk[splitIndex + 4 .. ^1]
+ content = stripTrailingCrlf(content)
+
+ var name = ""
+ var filename = ""
+ var contentType = "application/octet-stream"
+
+ for line in headerBlock.split("\r\n"):
+ let separator = line.find(':')
+ if separator <= 0:
+ continue
+ let headerName = line[0 ..< separator].strip().toLowerAscii()
+ let headerValue = line[separator + 1 .. ^1].strip()
+
+ if headerName == "content-disposition":
+ for part in headerValue.split(';'):
+ let token = part.strip()
+ if token.startsWith("name="):
+ name = token[5 .. ^1].strip(chars = {'\"', '\''})
+ elif token.startsWith("filename="):
+ filename = token[9 .. ^1].strip(chars = {'\"', '\''})
+ elif headerName == "content-type":
+ contentType = headerValue
+
+ if name.len > 0:
+ result.add(MultipartPart(name: name, filename: filename, contentType: contentType, content: content))
+
+proc isSafeRelativePath(pathPart: string): bool =
+ pathPart.len > 0 and
+ not pathPart.contains("..") and
+ not pathPart.contains('\\') and
+ not pathPart.startsWith("/")
+
+proc fileContentType(filePath: string): string =
+ let lowered = filePath.toLowerAscii()
+ if lowered.endsWith(".js"):
+ return "application/javascript; charset=utf-8"
+ if lowered.endsWith(".css"):
+ return "text/css; charset=utf-8"
+ if lowered.endsWith(".html"):
+ return "text/html; charset=utf-8"
+ if lowered.endsWith(".png"):
+ return "image/png"
+ if lowered.endsWith(".jpg") or lowered.endsWith(".jpeg"):
+ return "image/jpeg"
+ if lowered.endsWith(".gif"):
+ return "image/gif"
+ if lowered.endsWith(".webp"):
+ return "image/webp"
+ if lowered.endsWith(".svg"):
+ return "image/svg+xml"
+ if lowered.endsWith(".pdf"):
+ return "application/pdf"
+ "application/octet-stream"
+
+# response wrappers
+proc respondHtml(req: Request; code: HttpCode; content: string) {.async.} =
+ let headers = newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})
+ await req.respond(code, content, headers)
+
+proc respondText(req: Request; code: HttpCode; content: string) {.async.} =
+ let headers = newHttpHeaders({"Content-Type": "text/plain; charset=utf-8"})
+ await req.respond(code, content, headers)
+
+proc respondFile(req: Request; filePath: string; asAttachment: bool = false; attachmentName: string = "") {.async.} =
+ if not fileExists(filePath):
+ await respondText(req, Http404, "Not found")
+ return
+
+ var headers = newHttpHeaders()
+ headers["Content-Type"] = fileContentType(filePath)
+ if asAttachment and attachmentName.len > 0:
+ headers["Content-Disposition"] = "attachment; filename=\"" & attachmentName & "\""
+
+ await req.respond(Http200, readFile(filePath), headers)
+
+# pandoc does the heavy lifting
+proc runPandoc(sourceMarkdown: string; outputPath: string; paperSize: string; margin: string; mainFont: string): tuple[ok: bool, output: string, missingPandoc: bool] =
+ let tempDir = getTempDir() / (AppName & "-" & randomHex(10))
+ createDir(tempDir)
+ let tempMarkdownPath = tempDir / "source.md"
+
+ try:
+ writeFile(tempMarkdownPath, sourceMarkdown)
+
+ let args = @[
+ tempMarkdownPath,
+ "--from", "markdown+emoji",
+ "--pdf-engine=lualatex",
+ "--template", latexTemplatePath(),
+ "-V", "papersize=" & paperSize,
+ "-V", "margin=" & margin,
+ "-V", "mainfont=" & mainFont,
+ "--resource-path", baseDir() & ":" & uploadsDir() & ":" & tempDir,
+ "-o", outputPath
+ ]
+
+ var process: Process
+ try:
+ process = startProcess("pandoc", args = args, options = {poUsePath, poStdErrToStdOut})
+ except OSError:
+ return (ok: false, output: "Pandoc is not installed or not in PATH.", missingPandoc: true)
+
+ let output = process.outputStream.readAll()
+ let exitCode = process.waitForExit()
+ process.close()
+
+ if exitCode == 0:
+ return (ok: true, output: "", missingPandoc: false)
+ return (ok: false, output: output, missingPandoc: false)
+ finally:
+ try:
+ if fileExists(tempMarkdownPath):
+ removeFile(tempMarkdownPath)
+ if dirExists(tempDir):
+ removeDir(tempDir)
+ except OSError:
+ discard
+
+# app endpoint: strict inputs, loud errors.
+proc handleConvert(req: Request) {.async.} =
+ let formData = parseUrlEncoded(req.body)
+ let markdown = formData.getOrDefault("markdown", "").strip()
+
+ if markdown.len == 0:
+ let html = renderTemplate(partialsDir() / "error.html", [("{{ message }}", "Markdown content is required.")])
+ await respondHtml(req, Http400, html)
+ return
+
+ let paperSize = pickOption(formData.getOrDefault("paper_size", ""), "a4paper", ValidPaperSizes)
+ let margin = pickOption(formData.getOrDefault("margin", ""), "1in", ValidMargins)
+
+ var mainFontFamily = formData.getOrDefault("main_font", "serif")
+ if mainFontFamily != "serif" and mainFontFamily != "sans":
+ mainFontFamily = "serif"
+
+ let mainFont = if mainFontFamily == "sans": "TeX Gyre Heros" else: "TeX Gyre Pagella"
+ let epoch = int(getTime().toUnix())
+ let outputName = AppName & "_" & $epoch & "_" & randomHex(32) & ".pdf"
+ let outputPath = generatedDir() / outputName
+
+ let conversion = runPandoc(markdown, outputPath, paperSize, margin, mainFont)
+
+ if not conversion.ok:
+ let message = if conversion.missingPandoc:
+ conversion.output
+ else:
+ let stderr = conversion.output.strip()
+ if stderr.len > 0: tailText(stderr) else: "PDF conversion failed."
+
+ let html = renderTemplate(partialsDir() / "error.html", [("{{ message }}", htmlEscape(message))])
+ let code = if conversion.missingPandoc: Http500 else: Http400
+ await respondHtml(req, code, html)
+ return
+
+ let html = renderTemplate(
+ partialsDir() / "result.html",
+ [
+ ("{{ filename }}", htmlEscape(outputName)),
+ ("{{ download_url }}", "/download/" & encodeUrl(outputName))
+ ]
+ )
+ await respondHtml(req, Http200, html)
+
+# upload endpoint. accepts image, returns markdown snippet
+proc handleUploadImage(req: Request) {.async.} =
+ let contentType = req.headers.getOrDefault("Content-Type")
+ let boundary = extractBoundary(contentType)
+
+ if boundary.len == 0:
+ let html = renderTemplate(partialsDir() / "upload_error.html", [("{{ message }}", "image file is required.")])
+ await respondHtml(req, Http400, html)
+ return
+
+ let parts = parseMultipart(req.body, boundary)
+ var imagePart: MultipartPart
+ var foundImage = false
+ for part in parts:
+ if part.name == "image":
+ imagePart = part
+ foundImage = true
+ break
+
+ if not foundImage or imagePart.filename.strip().len == 0:
+ let html = renderTemplate(partialsDir() / "upload_error.html", [("{{ message }}", "image file is required.")])
+ await respondHtml(req, Http400, html)
+ return
+
+ let originalName = sanitizeFilename(baseFilename(imagePart.filename))
+ if originalName.len == 0 or not isAllowedImage(originalName):
+ let html = renderTemplate(partialsDir() / "upload_error.html", [("{{ message }}", "unsupported image type.")])
+ await respondHtml(req, Http400, html)
+ return
+
+ let extensionStart = originalName.rfind('.')
+ let extension = originalName[extensionStart + 1 .. ^1].toLowerAscii()
+
+ let epoch = int(getTime().toUnix())
+ let storedName = "img_" & $epoch & "_" & randomHex(32) & "." & extension
+ let imagePath = uploadsDir() / storedName
+
+ writeFile(imagePath, imagePart.content)
+
+ let markdownSnippet = "![](uploads/" & storedName & ")"
+ let html = renderTemplate(
+ partialsDir() / "upload_result.html",
+ [
+ ("{{ filename }}", htmlEscape(storedName)),
+ ("{{ markdown_snippet }}", htmlEscape(markdownSnippet)),
+ ("{{ preview_url }}", "/uploads/" & encodeUrl(storedName))
+ ]
+ )
+ await respondHtml(req, Http200, html)
+
+# router table
+proc route(req: Request) {.async.} =
+ let path = req.url.path
+
+ if req.reqMethod == HttpGet and path == "/":
+ await respondFile(req, templatesDir() / "index.html")
+ return
+
+ if req.reqMethod == HttpGet and path.startsWith("/static/"):
+ let relativePath = decodeUrl(path[8 .. ^1])
+ if not isSafeRelativePath(relativePath):
+ await respondText(req, Http400, "Invalid path")
+ return
+ await respondFile(req, staticDir() / relativePath)
+ return
+
+ if req.reqMethod == HttpGet and path.startsWith("/uploads/"):
+ let relativePath = decodeUrl(path[9 .. ^1])
+ if not isSafeRelativePath(relativePath):
+ await respondText(req, Http400, "Invalid path")
+ return
+ await respondFile(req, uploadsDir() / relativePath)
+ return
+
+ if req.reqMethod == HttpGet and path.startsWith("/download/"):
+ let relativePath = decodeUrl(path[10 .. ^1])
+ if not isSafeRelativePath(relativePath):
+ await respondText(req, Http400, "Invalid path")
+ return
+ await respondFile(req, generatedDir() / relativePath, asAttachment = true, attachmentName = relativePath)
+ return
+
+ if req.reqMethod == HttpPost and path == "/convert":
+ await handleConvert(req)
+ return
+
+ if req.reqMethod == HttpPost and path == "/upload-image":
+ await handleUploadImage(req)
+ return
+
+ await respondText(req, Http404, "Not found")
+
+# server boot, then we let htmx do htmx things.
+when isMainModule:
+ randomize()
+
+ if not dirExists(generatedDir()):
+ createDir(generatedDir())
+ if not dirExists(uploadsDir()):
+ createDir(uploadsDir())
+
+ let server = newAsyncHttpServer()
+ echo "listening on http://localhost:5000"
+ waitFor server.serve(Port(5000), route) \ No newline at end of file
diff --git a/src/app.py b/src/app.py
deleted file mode 100644
index 6b5a45a..0000000
--- a/src/app.py
+++ /dev/null
@@ -1,166 +0,0 @@
-from __future__ import annotations
-
-import subprocess
-import tempfile
-import time
-import uuid
-from pathlib import Path
-
-from flask import Flask, render_template, request, send_from_directory, url_for
-from werkzeug.utils import secure_filename
-
-app = Flask(__name__)
-
-BASE_DIR = Path(__file__).resolve().parent
-GENERATED_DIR = BASE_DIR / "generated"
-UPLOADS_DIR = BASE_DIR / "uploads"
-LATEX_TEMPLATE = BASE_DIR / "latex" / "template.tex"
-
-GENERATED_DIR.mkdir(parents=True, exist_ok=True)
-UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
-
-ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "svg"}
-
-PAPER_SIZES = {
- "a4paper": "A4",
- "letterpaper": "US Letter",
- "legalpaper": "US Legal",
-}
-
-MARGINS = {
- "0.75in": "Narrow (0.75in)",
- "1in": "Normal (1in)",
- "1.25in": "Comfort (1.25in)",
- "1.5in": "Wide (1.5in)",
-}
-
-MAIN_FONTS = {
- "serif": "TeX Gyre Pagella",
- "sans": "TeX Gyre Heros",
-}
-
-
-def _pick(options: dict[str, str], key: str, default_key: str) -> str:
- if key in options:
- return key
- return default_key
-
-
-def _is_allowed_image(filename: str) -> bool:
- if "." not in filename:
- return False
- extension = filename.rsplit(".", 1)[1].lower()
- return extension in ALLOWED_IMAGE_EXTENSIONS
-
-
-@app.get("/")
-def index():
- return render_template(
- "index.html",
- paper_sizes=PAPER_SIZES,
- margins=MARGINS,
- )
-
-
-@app.post("/convert")
-def convert_markdown():
- markdown = request.form.get("markdown", "").strip()
- if not markdown:
- return render_template("partials/error.html", message="Markdown content is required."), 400
-
- paper_size_key = _pick(PAPER_SIZES, request.form.get("paper_size", ""), "a4paper")
- margin_key = _pick(MARGINS, request.form.get("margin", ""), "1in")
- main_family_key = request.form.get("main_font", "serif")
-
- if main_family_key not in MAIN_FONTS:
- main_family_key = "serif"
-
- epoch = int(time.time())
- unique_id = uuid.uuid4().hex
- output_name = f"likha-pdf_{epoch}_{unique_id}.pdf"
- output_path = GENERATED_DIR / output_name
-
- with tempfile.TemporaryDirectory() as tmp_dir:
- temp_markdown = Path(tmp_dir) / "source.md"
- temp_markdown.write_text(markdown, encoding="utf-8")
-
- command = [
- "pandoc",
- str(temp_markdown),
- "--from",
- "markdown+emoji",
- "--pdf-engine=lualatex",
- "--template",
- str(LATEX_TEMPLATE),
- "-V",
- f"papersize={paper_size_key}",
- "-V",
- f"margin={margin_key}",
- "-V",
- f"mainfont={MAIN_FONTS[main_family_key]}",
- "--resource-path",
- f"{BASE_DIR}:{UPLOADS_DIR}:{tmp_dir}",
- "-o",
- str(output_path),
- ]
-
- try:
- subprocess.run(command, check=True, capture_output=True, text=True)
- except FileNotFoundError:
- return (
- render_template(
- "partials/error.html",
- message="Pandoc is not installed or not in PATH.",
- ),
- 500,
- )
- except subprocess.CalledProcessError as exc:
- stderr = (exc.stderr or "").strip()
- error_message = stderr[-1200:] if stderr else "PDF conversion failed."
- return render_template("partials/error.html", message=error_message), 400
-
- return render_template(
- "partials/result.html",
- download_url=url_for("download_pdf", filename=output_name),
- filename=output_name,
- )
-
-
-@app.post("/upload-image")
-def upload_image():
- image = request.files.get("image")
- if image is None or image.filename is None or not image.filename.strip():
- return render_template("partials/upload_error.html", message="image file is required."), 400
-
- original_name = secure_filename(image.filename)
- if not original_name or not _is_allowed_image(original_name):
- return render_template("partials/upload_error.html", message="unsupported image type."), 400
-
- extension = original_name.rsplit(".", 1)[1].lower()
- epoch = int(time.time())
- unique_id = uuid.uuid4().hex
- stored_name = f"img_{epoch}_{unique_id}.{extension}"
- image_path = UPLOADS_DIR / stored_name
- image.save(image_path)
-
- markdown_snippet = f"![]({(Path('uploads') / stored_name).as_posix()})"
- return render_template(
- "partials/upload_result.html",
- filename=stored_name,
- markdown_snippet=markdown_snippet,
- preview_url=url_for("uploaded_image", filename=stored_name),
- )
-
-
-@app.get("/uploads/<path:filename>")
-def uploaded_image(filename: str):
- return send_from_directory(UPLOADS_DIR, filename)
-
-
-@app.get("/download/<path:filename>")
-def download_pdf(filename: str):
- return send_from_directory(GENERATED_DIR, filename, as_attachment=True)
-
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5000, debug=False)
diff --git a/src/requirements.txt b/src/requirements.txt
deleted file mode 100644
index 4a4d074..0000000
--- a/src/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-Flask>=3.0.0,<4.0.0
diff --git a/src/templates/index.html b/src/templates/index.html
index 42f5f5c..e67f582 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -61,16 +61,17 @@
<label for="paper_size">Paper size</label>
<select id="paper_size" name="paper_size">
- {% for key, label in paper_sizes.items() %}
- <option value="{{ key }}">{{ label }}</option>
- {% endfor %}
+ <option value="a4paper">A4</option>
+ <option value="letterpaper">US Letter</option>
+ <option value="legalpaper">US Legal</option>
</select>
<label for="margin">Margins</label>
<select id="margin" name="margin">
- {% for key, label in margins.items() %}
- <option value="{{ key }}" {% if key == "1in" %}selected{% endif %}>{{ label }}</option>
- {% endfor %}
+ <option value="0.75in">Narrow (0.75in)</option>
+ <option value="1in" selected>Normal (1in)</option>
+ <option value="1.25in">Comfort (1.25in)</option>
+ <option value="1.5in">Wide (1.5in)</option>
</select>
<fieldset>
@@ -91,6 +92,6 @@
<section id="result" aria-live="polite"></section>
</main>
- <script src="{{ url_for('static', filename='main.js') }}"></script>
+ <script src="/static/main.js"></script>
</body>
</html>