aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-05-31 12:08:03 -0400
committerkj_sh6042026-05-31 12:08:03 -0400
commit80e279b65351d4dbd8e71cd7b3870cba51c23bc9 (patch)
tree4011a93d5481d2ce3907cf7e2d0fe057e5bc4030
parent2b2e511a15414f6c28f15990b75442424f9cc3b9 (diff)
refactor: put generated .pdfs to client
-rw-r--r--src/app.py82
-rw-r--r--src/static/main.js273
-rw-r--r--src/templates/index.html2
-rw-r--r--src/templates/partials/result.html1
4 files changed, 254 insertions, 104 deletions
diff --git a/src/app.py b/src/app.py
index 72fb78d..1b3c85c 100644
--- a/src/app.py
+++ b/src/app.py
@@ -4,10 +4,11 @@
# production-friendly flask app with weasyprint + reportlab fallback
import logging
+import io
import os
import secrets
import time
-from pathlib import Path, PurePosixPath
+from pathlib import Path
from flask import (
Flask,
@@ -15,7 +16,6 @@ from flask import (
current_app,
request,
send_from_directory,
- abort,
)
from markupsafe import escape
from markdown import markdown
@@ -29,7 +29,6 @@ DEFAULT_MAX_CONTENT_LENGTH = 512 * 1024 * 1024
DEFAULT_MAX_FORM_MEMORY_SIZE = DEFAULT_MAX_CONTENT_LENGTH
BASE_DIR = Path(__file__).resolve().parent
-GENERATED_DIR = BASE_DIR / "generated"
TEMPLATES_DIR = BASE_DIR / "templates"
PARTIALS_DIR = TEMPLATES_DIR / "partials"
STATIC_DIR = BASE_DIR / "static"
@@ -130,25 +129,10 @@ def env_bool(name, default=False):
return raw.strip().lower() in {"1", "true", "yes", "on"}
-def ensure_runtime_dirs():
- GENERATED_DIR.mkdir(parents=True, exist_ok=True)
-
-
-def random_hex(length=32):
- return secrets.token_hex(length // 2)
-
-
def pick_option(value, fallback, valid):
return value if value in valid else fallback
-def is_safe_relative_path(path_part):
- if not path_part or "\\" in path_part:
- return False
- safe_path = PurePosixPath(path_part)
- return not safe_path.is_absolute() and ".." not in safe_path.parts
-
-
def read_partial(name, replacements=None):
"""read a partial html template and apply replacements"""
content = (PARTIALS_DIR / name).read_text(encoding="utf-8")
@@ -382,21 +366,20 @@ def build_full_html(body_html, css):
</html>"""
-def convert_with_weasyprint(full_html, output_path):
- """render html to pdf via weasyprint. returns (ok, error_msg)."""
+def convert_with_weasyprint(full_html):
+ """render html to pdf via weasyprint. returns (ok, pdf_bytes, error_msg)."""
try:
doc = HTML(
string=full_html,
base_url=str(BASE_DIR),
)
- doc.write_pdf(output_path)
- return True, ""
+ return True, doc.write_pdf(), ""
except Exception as exc:
- return False, str(exc)
+ return False, b"", str(exc)
def convert_with_reportlab(
- source_markdown, output_path, paper_size, margin, font_family, line_spacing
+ source_markdown, paper_size, margin, font_family, line_spacing
):
"""fallback: produce a basic text pdf with reportlab.
not pretty, but guarantees a file is always created."""
@@ -466,8 +449,10 @@ def convert_with_reportlab(
pagesize = size_map.get(paper_size, LETTER)
m = margin_map.get(margin, 1.0 * inch)
+ buffer = io.BytesIO()
+
doc = SimpleDocTemplate(
- output_path,
+ buffer,
pagesize=pagesize,
leftMargin=m,
rightMargin=m,
@@ -547,11 +532,11 @@ def convert_with_reportlab(
story.append(Preformatted(code_text, code_style))
doc.build(story)
+ return buffer.getvalue()
def generate_pdf(
source_markdown,
- output_path,
paper_size,
margin,
font_family,
@@ -572,31 +557,28 @@ def generate_pdf(
)
full_html = build_full_html(body_html, css)
- ok, err = convert_with_weasyprint(full_html, output_path)
+ ok, pdf_bytes, err = convert_with_weasyprint(full_html)
if ok:
- return True, ""
+ return True, pdf_bytes, ""
# weasyprint failed — fall back to reportlab
try:
current_app.logger.warning(
"weasyprint failed, using reportlab fallback: %s", err
)
- convert_with_reportlab(
+ pdf_bytes = convert_with_reportlab(
source_markdown,
- output_path,
paper_size,
margin,
font_family,
line_spacing,
)
- return True, f"(used fallback renderer) {err}"
+ return True, pdf_bytes, f"(used fallback renderer) {err}"
except Exception as fallback_err:
- return False, f"weasyprint: {err} | reportlab: {fallback_err}"
+ return False, b"", f"weasyprint: {err} | reportlab: {fallback_err}"
def create_app():
- ensure_runtime_dirs()
-
app = Flask(
__name__,
template_folder=str(TEMPLATES_DIR),
@@ -701,12 +683,12 @@ def create_app():
)
disable_backgrounds = request.form.get("disable_backgrounds") == "on"
- output_name = f"{APP_NAME}_{int(time.time())}_{random_hex()}.pdf"
- output_path = GENERATED_DIR / output_name
+ download_name = (
+ f"{APP_NAME}_{int(time.time())}_{secrets.token_hex(20)}.pdf"
+ )
- ok, err = generate_pdf(
+ ok, pdf_bytes, err = generate_pdf(
md,
- str(output_path),
paper_size,
margin,
font_family,
@@ -728,25 +710,15 @@ def create_app():
500,
)
- return read_partial(
- "result.html",
- {
- "{{ filename }}": str(escape(output_name)),
- "{{ download_url }}": f"/download/{output_name}",
- },
- )
+ if err:
+ app.logger.warning("pdf generated with fallback renderer: %s", err)
- @app.route("/download/<path:filename>")
- def download(filename):
- if not is_safe_relative_path(filename):
- abort(400)
- return send_from_directory(
- str(GENERATED_DIR),
- filename,
- as_attachment=True,
- download_name=filename,
- conditional=True,
+ response = Response(pdf_bytes, mimetype="application/pdf")
+ response.headers["Content-Disposition"] = (
+ f'attachment; filename="{download_name}"'
)
+ response.headers["Cache-Control"] = "no-store"
+ return response
return app
diff --git a/src/static/main.js b/src/static/main.js
index 180bb91..ad98fa0 100644
--- a/src/static/main.js
+++ b/src/static/main.js
@@ -3,7 +3,7 @@ const uploadButton = document.getElementById("upload-button");
const markdownInput = document.getElementById("markdown");
const imageInput = document.getElementById("image");
const convertForm = document.getElementById("convert-form");
-const loadingIndicator = document.getElementById("loading");
+const convertStatusMessage = document.getElementById("convert-status");
const resultContainer = document.getElementById("result");
const uploadResultContainer = document.getElementById("upload-result");
@@ -11,12 +11,15 @@ const PERSISTENCE_KEY = "likha-pdf:form-state:v1";
const IMAGE_DB_NAME = "likha-pdf:image-db:v1";
const IMAGE_DB_VERSION = 1;
const IMAGE_STORE_NAME = "images";
+const PDF_FILENAME_KEY = "likha-pdf:last-pdf-filename";
+const SNIPPET_DETAILS_OPEN_KEY = "likha-pdf:snippet-details-open:v1";
const LOCAL_IMAGE_SCHEME = "local-image://";
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
const MAX_STORAGE_BYTES = 25 * 1024 * 1024 * 1024;
const MAX_CONVERT_REQUEST_BYTES = 512 * 1024 * 1024;
const LOCAL_IMAGE_TOKEN_PATTERN = /local-image:\/\/([a-zA-Z0-9-]+)/g;
const ALLOWED_IMAGE_EXT_PATTERN = /\.(png|jpe?g|gif|webp|svg)$/i;
+let snippetDetailsIsOpen = readPersistedBoolean(SNIPPET_DETAILS_OPEN_KEY, false);
function readPersistedState() {
try {
@@ -35,6 +38,31 @@ function writePersistedState(value) {
}
}
+function readPersistedBoolean(key, fallback = false) {
+ try {
+ const raw = localStorage.getItem(key);
+ if (raw === null) {
+ return fallback;
+ }
+ return raw === "true";
+ } catch {
+ return fallback;
+ }
+}
+
+function writePersistedBoolean(key, value) {
+ try {
+ localStorage.setItem(key, value ? "true" : "false");
+ } catch {
+ // ignore persistence failures in restricted storage contexts
+ }
+}
+
+function setSnippetDetailsOpen(isOpen) {
+ snippetDetailsIsOpen = Boolean(isOpen);
+ writePersistedBoolean(SNIPPET_DETAILS_OPEN_KEY, snippetDetailsIsOpen);
+}
+
function collectFormState() {
if (!(convertForm instanceof HTMLFormElement)) {
return;
@@ -134,8 +162,8 @@ function setConvertLoadingState(isLoading) {
convertButton.textContent = isLoading ? "generating..." : "generate pdf";
}
- if (loadingIndicator instanceof HTMLElement) {
- loadingIndicator.hidden = !isLoading;
+ if (convertStatusMessage instanceof HTMLElement) {
+ convertStatusMessage.hidden = !isLoading;
}
}
@@ -160,6 +188,18 @@ function buildLocalImageSnippet(name, id) {
return `![${cleanName}](${LOCAL_IMAGE_SCHEME}${id})`;
}
+function insertSnippetIntoMarkdown(snippet) {
+ if (!markdownInput) {
+ return;
+ }
+
+ const needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n");
+ const prefix = needsLeadingNewline ? "\n" : "";
+ markdownInput.value += `${prefix}${snippet}\n`;
+ markdownInput.focus();
+ collectFormState();
+}
+
function isAllowedImageFile(file) {
if (file.type.startsWith("image/")) {
return true;
@@ -176,6 +216,24 @@ function makeImageId() {
return `img-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
}
+function randomHex(length = 40) {
+ const byteLen = Math.ceil(length / 2);
+
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
+ const bytes = new Uint8Array(byteLen);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, "0"))
+ .join("")
+ .slice(0, length);
+ }
+
+ let out = "";
+ while (out.length < length) {
+ out += Math.random().toString(16).slice(2);
+ }
+ return out.slice(0, length);
+}
+
function requestToPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
@@ -323,42 +381,114 @@ async function resolveLocalImageTokens(markdown) {
return { resolvedMarkdown, missingIds };
}
-let activePreviewUrl = "";
+async function getImageSnippetHistory() {
+ const db = await openImageDb();
-function showUploadError(message) {
- if (!(uploadResultContainer instanceof HTMLElement)) {
- return;
- }
+ try {
+ const transaction = db.transaction(IMAGE_STORE_NAME, "readonly");
+ const store = transaction.objectStore(IMAGE_STORE_NAME);
+ const snippets = [];
- uploadResultContainer.innerHTML = `
- <article>
- <h4>image upload failed</h4>
- <pre>${escapeHtml(message)}</pre>
- </article>
- `;
+ await new Promise((resolve, reject) => {
+ const request = store.openCursor();
+
+ request.onsuccess = () => {
+ const cursor = request.result;
+ if (!cursor) {
+ resolve();
+ return;
+ }
+
+ const value = cursor.value || {};
+ const id = typeof value.id === "string" ? value.id : "";
+ if (id) {
+ snippets.push({
+ createdAt: Number(value.createdAt) || 0,
+ snippet: buildLocalImageSnippet(value.name, id),
+ });
+ }
+
+ cursor.continue();
+ };
+
+ request.onerror = () =>
+ reject(request.error || new Error("failed to read image snippet history"));
+ });
+
+ await transactionDone(transaction);
+ snippets.sort((a, b) => b.createdAt - a.createdAt);
+ return snippets.map((entry) => entry.snippet);
+ } finally {
+ db.close();
+ }
}
-function showUploadResult(record) {
+function renderSnippetHistory(snippets, heading = "", message = "") {
if (!(uploadResultContainer instanceof HTMLElement)) {
return;
}
- if (activePreviewUrl) {
- URL.revokeObjectURL(activePreviewUrl);
- activePreviewUrl = "";
+ if (snippets.length === 0 && !heading && !message) {
+ uploadResultContainer.innerHTML = "";
+ return;
}
- const snippet = buildLocalImageSnippet(record.name, record.id);
- activePreviewUrl = URL.createObjectURL(record.blob);
+ const headingHtml = heading ? `<h4>${escapeHtml(heading)}</h4>` : "";
+ const messageHtml = message ? `<p>${escapeHtml(message)}</p>` : "";
+
+ let detailsHtml = "";
+ if (snippets.length > 0) {
+ const openAttr = snippetDetailsIsOpen ? " open" : "";
+ const snippetItems = snippets
+ .map(
+ (snippet) =>
+ `<li style="all: revert;"><code style="all: revert;">${escapeHtml(snippet)}</code></li>`
+ )
+ .join("");
+ detailsHtml = `
+ <br>
+ <details style="all: revert;" data-snippet-history="true"${openAttr}>
+ <summary style="all: revert; cursor: pointer;">images (${snippets.length})</summary>
+ <ol style="all: revert;">${snippetItems}</ol>
+ </details><br>
+ `;
+ }
uploadResultContainer.innerHTML = `
<article>
- <h4>image ready for insert</h4>
- <p><a href="${escapeHtml(activePreviewUrl)}" target="_blank" rel="noreferrer">${escapeHtml(record.name)}</a></p>
- <p><code id="uploaded-markdown">${escapeHtml(snippet)}</code></p>
- <button type="button" data-insert-markdown="${escapeHtml(snippet)}" class="insert-markdown">insert into markdown</button>
+ ${headingHtml}
+ ${messageHtml}
+ ${detailsHtml}
</article>
`;
+
+ const renderedDetails = uploadResultContainer.querySelector(
+ 'details[data-snippet-history="true"]'
+ );
+ if (renderedDetails instanceof HTMLDetailsElement) {
+ renderedDetails.addEventListener("toggle", () => {
+ setSnippetDetailsOpen(renderedDetails.open);
+ });
+ }
+}
+
+async function refreshSnippetHistory(heading = "", message = "") {
+ try {
+ const snippets = await getImageSnippetHistory();
+ renderSnippetHistory(snippets, heading, message);
+ } catch {
+ renderSnippetHistory([], "image upload failed", "unable to load image snippet history.");
+ }
+}
+
+function showUploadError(message) {
+ void refreshSnippetHistory("image upload failed", message);
+}
+
+async function showUploadResult(record) {
+ const snippet = buildLocalImageSnippet(record.name, record.id);
+ insertSnippetIntoMarkdown(snippet);
+ await refreshSnippetHistory("image inserted", record.name || "image");
}
async function handleInsertImage() {
@@ -401,7 +531,7 @@ async function handleInsertImage() {
};
await saveImageRecord(record);
- showUploadResult(record);
+ await showUploadResult(record);
imageInput.value = "";
} catch (error) {
const message =
@@ -428,6 +558,68 @@ function showConvertError(message) {
resultContainer.scrollIntoView({ behavior: "smooth", block: "start" });
}
+let activePdfUrl = "";
+
+function clearActivePdfUrl() {
+ if (activePdfUrl) {
+ URL.revokeObjectURL(activePdfUrl);
+ activePdfUrl = "";
+ }
+}
+
+function sanitizeDownloadFilename(filename) {
+ const epoch = Math.floor(Date.now() / 1000);
+ const fallback = `likha-pdf_${epoch}_${randomHex(40)}.pdf`;
+ if (!filename) {
+ return fallback;
+ }
+
+ const sanitized = filename.replaceAll(/[^a-zA-Z0-9._()\- ]/g, "_").trim();
+ return sanitized || fallback;
+}
+
+function getDownloadFilenameFromResponse(response) {
+ const disposition = response.headers.get("content-disposition") || "";
+ const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
+ if (utf8Match && utf8Match[1]) {
+ try {
+ return sanitizeDownloadFilename(decodeURIComponent(utf8Match[1]));
+ } catch {
+ return sanitizeDownloadFilename(utf8Match[1]);
+ }
+ }
+
+ const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
+ if (plainMatch && plainMatch[1]) {
+ return sanitizeDownloadFilename(plainMatch[1]);
+ }
+
+ return sanitizeDownloadFilename("");
+}
+
+function showPdfReady(pdfBlob, downloadFilename) {
+ if (!(resultContainer instanceof HTMLElement)) {
+ return;
+ }
+
+ try {
+ localStorage.setItem(PDF_FILENAME_KEY, downloadFilename);
+ } catch {
+ // ignore storage write failures
+ }
+
+ clearActivePdfUrl();
+ activePdfUrl = URL.createObjectURL(pdfBlob);
+
+ resultContainer.innerHTML = `
+ <article>
+ <h3>pdf ready</h3>
+ <a href="${escapeHtml(activePdfUrl)}" download="${escapeHtml(downloadFilename)}">download pdf</a>
+ </article>
+ `;
+ resultContainer.scrollIntoView({ behavior: "smooth", block: "start" });
+}
+
async function handleConvertSubmit(event) {
event.preventDefault();
@@ -468,6 +660,14 @@ async function handleConvertSubmit(event) {
body: formData,
});
+ const contentType = (response.headers.get("content-type") || "").toLowerCase();
+ if (response.ok && contentType.includes("application/pdf")) {
+ const pdfBlob = await response.blob();
+ const downloadFilename = getDownloadFilenameFromResponse(response);
+ showPdfReady(pdfBlob, downloadFilename);
+ return;
+ }
+
const responseHtml = await response.text();
if (resultContainer instanceof HTMLElement) {
resultContainer.innerHTML = responseHtml;
@@ -500,26 +700,5 @@ if (uploadButton instanceof HTMLButtonElement) {
});
}
-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();
- collectFormState();
-});
+void refreshSnippetHistory();
diff --git a/src/templates/index.html b/src/templates/index.html
index 3af58ad..3bd878e 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -114,7 +114,7 @@
</section>
<button id="convert-button" type="submit">generate pdf</button>
- <small id="loading" hidden>converting...</small>
+ <small id="convert-status" hidden>this may take a while...</small>
</form>
<section id="result" aria-live="polite"></section>
diff --git a/src/templates/partials/result.html b/src/templates/partials/result.html
index 427382d..6f9b7c7 100644
--- a/src/templates/partials/result.html
+++ b/src/templates/partials/result.html
@@ -1,5 +1,4 @@
<article>
<h3>pdf ready</h3>
- <p><strong>{{ filename }}</strong></p>
<a href="{{ download_url }}">download pdf</a>
</article>