From afd82dc38510c6a47cfa227f40f1dae76e1a526c Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Mon, 16 Feb 2026 01:42:50 -0500 Subject: refactor: move to src/ --- src/.gitignore | 5 + src/app.py | 166 ++++++++++++++++++++++++++++++ src/latex/template.tex | 78 ++++++++++++++ src/requirements.txt | 1 + src/static/main.js | 59 +++++++++++ src/templates/index.html | 96 +++++++++++++++++ src/templates/partials/error.html | 4 + src/templates/partials/result.html | 5 + src/templates/partials/upload_error.html | 4 + src/templates/partials/upload_result.html | 12 +++ 10 files changed, 430 insertions(+) create mode 100644 src/.gitignore create mode 100644 src/app.py create mode 100644 src/latex/template.tex create mode 100644 src/requirements.txt create mode 100644 src/static/main.js create mode 100644 src/templates/index.html create mode 100644 src/templates/partials/error.html create mode 100644 src/templates/partials/result.html create mode 100644 src/templates/partials/upload_error.html create mode 100644 src/templates/partials/upload_result.html (limited to 'src') diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..fdbd64b --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +generated/ +uploads/ diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..5a3aa7b --- /dev/null +++ b/src/app.py @@ -0,0 +1,166 @@ +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/") +def uploaded_image(filename: str): + return send_from_directory(UPLOADS_DIR, filename) + + +@app.get("/download/") +def download_pdf(filename: str): + return send_from_directory(GENERATED_DIR, filename, as_attachment=True) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/src/latex/template.tex b/src/latex/template.tex new file mode 100644 index 0000000..9a0410b --- /dev/null +++ b/src/latex/template.tex @@ -0,0 +1,78 @@ +\documentclass[11pt]{article} + +\usepackage{fontspec} +\newfontfamily{\emojifont}{Noto Color Emoji}[Renderer=HarfBuzz] +\directlua{ + luaotfload.add_fallback("emojifallback", { + "Noto Color Emoji:mode=harf;" + }) +} +\setmainfont{$mainfont$}[RawFeature={fallback=emojifallback}] +\setmonofont{Latin Modern Mono} + +\usepackage[paper=$papersize$,margin=$margin$]{geometry} +\usepackage{microtype} +\usepackage{parskip} +\usepackage{xcolor} +\usepackage{graphicx} +\usepackage{float} +\usepackage{booktabs} +\usepackage{longtable} +\usepackage{array} +\usepackage{calc} +\usepackage{etoolbox} +\usepackage{fancyvrb} +\usepackage{fvextra} +\DefineVerbatimEnvironment{Highlighting}{Verbatim}{breaklines,commandchars=\\\{\}} +\usepackage{hyperref} +\hypersetup{ + colorlinks=true, + linkcolor=black, + urlcolor=blue, + citecolor=black, + pdfauthor={}, + pdftitle={likha-pdf} +} +\urlstyle{same} + +\setlength{\emergencystretch}{3em} +\setcounter{secnumdepth}{0} +\setkeys{Gin}{width=\linewidth,keepaspectratio} +\makeatletter +\newsavebox\pandoc@box +\newcommand*\pandocbounded[1]{% + \sbox\pandoc@box{#1}% + \Gscale@div\@tempa{\textheight}{\dimexpr\ht\pandoc@box+\dp\pandoc@box\relax}% + \Gscale@div\@tempb{\linewidth}{\wd\pandoc@box}% + \ifdim\@tempb\p@<\@tempa\p@ + \scalebox{\@tempb}{\usebox\pandoc@box}% + \else + \scalebox{\@tempa}{\usebox\pandoc@box}% + \fi +} +\makeatother +\let\origfigure\figure +\let\endorigfigure\endfigure +\renewenvironment{figure}[1][] { + \expandafter\origfigure\expandafter[H] +} { + \endorigfigure +} +\providecommand{\tightlist}{ + \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt} +} + +$if(highlighting-macros)$ +$highlighting-macros$ +$endif$ + +\begin{document} + +$if(title)$ +{\huge\bfseries $title$\par} +\vspace{1em} +$endif$ + +$body$ + +\end{document} \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..4a4d074 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1 @@ +Flask>=3.0.0,<4.0.0 diff --git a/src/static/main.js b/src/static/main.js new file mode 100644 index 0000000..f3f4f71 --- /dev/null +++ b/src/static/main.js @@ -0,0 +1,59 @@ +const convertButton = document.getElementById("convert-button"); +const uploadButton = document.getElementById("upload-button"); +const markdownInput = document.getElementById("markdown"); + +function sourceForm(event) { + const sourceElement = event.detail?.elt; + if (!sourceElement) { + return null; + } + return sourceElement.closest("form"); +} + +document.body.addEventListener("htmx:beforeRequest", (event) => { + const form = sourceForm(event); + if (form?.id === "convert-form" && convertButton) { + convertButton.disabled = true; + convertButton.textContent = "generating..."; + } + + if (form?.id === "upload-form" && uploadButton) { + uploadButton.disabled = true; + uploadButton.textContent = "uploading..."; + } +}); + +document.body.addEventListener("htmx:afterRequest", (event) => { + const form = sourceForm(event); + if (form?.id === "convert-form" && convertButton) { + convertButton.disabled = false; + convertButton.textContent = "generate pdf"; + } + + if (form?.id === "upload-form" && uploadButton) { + uploadButton.disabled = false; + uploadButton.textContent = "upload image"; + } +}); + +document.body.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const button = target.closest("[data-insert-markdown]"); + if (!(button instanceof HTMLElement) || !markdownInput) { + return; + } + + const snippet = button.dataset.insertMarkdown; + if (!snippet) { + return; + } + + const needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n"); + const prefix = needsLeadingNewline ? "\n" : ""; + markdownInput.value += `${prefix}${snippet}\n`; + markdownInput.focus(); +}); diff --git a/src/templates/index.html b/src/templates/index.html new file mode 100644 index 0000000..42f5f5c --- /dev/null +++ b/src/templates/index.html @@ -0,0 +1,96 @@ + + + + + + likha-pdf + + + + + +
+

likha-pdf

+

simple markdown export with pandoc + lualatex.

+ +
+

image upload

+
+ + + + uploading… +
+
+
+ +
+ + + +
+

pdf options

+ + + + + + + +
+ main font + + +
+
+ + + converting… +
+ +
+
+ + + + diff --git a/src/templates/partials/error.html b/src/templates/partials/error.html new file mode 100644 index 0000000..83825f1 --- /dev/null +++ b/src/templates/partials/error.html @@ -0,0 +1,4 @@ +
+

Conversion failed

+
{{ message }}
+
diff --git a/src/templates/partials/result.html b/src/templates/partials/result.html new file mode 100644 index 0000000..427382d --- /dev/null +++ b/src/templates/partials/result.html @@ -0,0 +1,5 @@ + diff --git a/src/templates/partials/upload_error.html b/src/templates/partials/upload_error.html new file mode 100644 index 0000000..d219783 --- /dev/null +++ b/src/templates/partials/upload_error.html @@ -0,0 +1,4 @@ +
+

upload failed

+
{{ message }}
+
diff --git a/src/templates/partials/upload_result.html b/src/templates/partials/upload_result.html new file mode 100644 index 0000000..116c74c --- /dev/null +++ b/src/templates/partials/upload_result.html @@ -0,0 +1,12 @@ +
+

image uploaded

+

{{ filename }}

+

{{ markdown_snippet }}

+ +
-- cgit v1.2.3