diff options
| author | kj_sh604 | 2026-05-31 12:08:03 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-05-31 12:08:03 -0400 |
| commit | 80e279b65351d4dbd8e71cd7b3870cba51c23bc9 (patch) | |
| tree | 4011a93d5481d2ce3907cf7e2d0fe057e5bc4030 | |
| parent | 2b2e511a15414f6c28f15990b75442424f9cc3b9 (diff) | |
refactor: put generated .pdfs to client
| -rw-r--r-- | src/app.py | 82 | ||||
| -rw-r--r-- | src/static/main.js | 273 | ||||
| -rw-r--r-- | src/templates/index.html | 2 | ||||
| -rw-r--r-- | src/templates/partials/result.html | 1 |
4 files changed, 254 insertions, 104 deletions
@@ -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 ``; } +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> |
