aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/.gitignore5
-rw-r--r--src/app.py166
-rw-r--r--src/latex/template.tex78
-rw-r--r--src/requirements.txt1
-rw-r--r--src/static/main.js59
-rw-r--r--src/templates/index.html96
-rw-r--r--src/templates/partials/error.html4
-rw-r--r--src/templates/partials/result.html5
-rw-r--r--src/templates/partials/upload_error.html4
-rw-r--r--src/templates/partials/upload_result.html12
10 files changed, 430 insertions, 0 deletions
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/<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(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 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>likha-pdf</title>
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css" />
+ <script src="https://unpkg.com/htmx.org@1.9.12"></script>
+ <style>
+ .htmx-indicator {
+ display: none;
+ }
+
+ .htmx-request .htmx-indicator,
+ .htmx-request.htmx-indicator {
+ display: inline;
+ }
+ </style>
+ </head>
+ <body>
+ <main>
+ <h1>likha-pdf</h1>
+ <p>simple markdown export with pandoc + lualatex.</p>
+
+ <section>
+ <h3>image upload</h3>
+ <form
+ id="upload-form"
+ hx-post="/upload-image"
+ hx-target="#upload-result"
+ hx-swap="innerHTML"
+ hx-encoding="multipart/form-data"
+ hx-indicator="#uploading"
+ >
+ <label for="image">image file</label>
+ <input id="image" name="image" type="file" accept="image/*" required />
+ <button id="upload-button" type="submit">upload image</button>
+ <small id="uploading" class="htmx-indicator">uploading…</small>
+ </form>
+ <div id="upload-result" aria-live="polite"></div>
+ </section>
+
+ <form
+ id="convert-form"
+ hx-post="/convert"
+ hx-target="#result"
+ hx-swap="innerHTML"
+ hx-indicator="#loading"
+ >
+ <label for="markdown"><strong>textarea</strong></label>
+ <textarea
+ id="markdown"
+ name="markdown"
+ rows="16"
+ placeholder="markdown goes here..."
+ required
+ ></textarea>
+
+ <section>
+ <h3>pdf options</h3>
+
+ <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 %}
+ </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 %}
+ </select>
+
+ <fieldset>
+ <legend>main font</legend>
+ <label>
+ <input type="radio" name="main_font" value="serif" checked /> serif
+ </label>
+ <label>
+ <input type="radio" name="main_font" value="sans" /> sans-serif
+ </label>
+ </fieldset>
+ </section>
+
+ <button id="convert-button" type="submit">generate pdf</button>
+ <small id="loading" class="htmx-indicator">converting…</small>
+ </form>
+
+ <section id="result" aria-live="polite"></section>
+ </main>
+
+ <script src="{{ url_for('static', filename='main.js') }}"></script>
+ </body>
+</html>
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 @@
+<article>
+ <h3>Conversion failed</h3>
+ <pre>{{ message }}</pre>
+</article>
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 @@
+<article>
+ <h3>pdf ready</h3>
+ <p><strong>{{ filename }}</strong></p>
+ <a href="{{ download_url }}">download pdf</a>
+</article>
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 @@
+<article>
+ <h4>upload failed</h4>
+ <pre>{{ message }}</pre>
+</article>
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 @@
+<article>
+ <h4>image uploaded</h4>
+ <p><a href="{{ preview_url }}" target="_blank" rel="noreferrer">{{ filename }}</a></p>
+ <p><code id="uploaded-markdown">{{ markdown_snippet }}</code></p>
+ <button
+ type="button"
+ data-insert-markdown="{{ markdown_snippet }}"
+ class="insert-markdown"
+ >
+ insert into markdown
+ </button>
+</article>