From 8853c79252bda5f12606ec0d8a233b50685f1d2f Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Fri, 20 Feb 2026 00:09:40 -0800 Subject: [PATCH 01/25] Port add-on to Thunderbird 140+ MailExtension --- Changelog | 8 + Makefile | 17 +- README.md | 72 +++++- api/TBLatex/implementation.js | 373 +++++++++++++++++++++++++++++++ api/TBLatex/schema.json | 62 ++++++ background.js | 332 ++++++++++++++++++++++++++++ compose/compose-script.js | 404 ++++++++++++++++++++++++++++++++++ manifest.json | 67 ++++-- ui/insert.css | 70 ++++++ ui/insert.html | 40 ++++ ui/insert.js | 140 ++++++++++++ ui/options.css | 84 +++++++ ui/options.html | 72 ++++++ ui/options.js | 110 +++++++++ 14 files changed, 1824 insertions(+), 27 deletions(-) create mode 100644 api/TBLatex/implementation.js create mode 100644 api/TBLatex/schema.json create mode 100644 background.js create mode 100644 compose/compose-script.js create mode 100644 ui/insert.css create mode 100644 ui/insert.html create mode 100644 ui/insert.js create mode 100644 ui/options.css create mode 100644 ui/options.html create mode 100644 ui/options.js diff --git a/Changelog b/Changelog index 7ae72d4..c0b8066 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,11 @@ +--v0.8.0 + +- Ported add-on to Thunderbird 140+ MailExtension architecture. +- Replaced legacy XUL overlay integration with compose action + compose scripts. +- Added a Thunderbird experiment API to run local latex/dvipng binaries. +- Replaced XUL options/insert dialogs with HTML options and popup dialogs. +- Added migration of existing legacy tblatex.* user preferences. + --vX.Y.Z - Improvements in the generated .png bitmaps: Transparent background diff --git a/Makefile b/Makefile index 2d7a12f..b87b295 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,17 @@ -EXCLUDES = $(addprefix --exclude , $(shell find . -iname '.*.sw*')) - all: dist -.PHONY: dist +.PHONY: dist clean dist: rm -f tblatex.xpi - zip tblatex.xpi $(EXCLUDES) --exclude Makefile --exclude TODO --exclude icon.xcf --exclude tblatex.xpi -r * + zip -r tblatex.xpi \ + manifest.json \ + icon.png \ + background.js \ + api \ + compose \ + ui \ + README.md \ + Changelog + +clean: + rm -f tblatex.xpi diff --git a/README.md b/README.md index 267131d..8915678 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,69 @@ Latex It! Description ----------- -This extension allows you to run LaTeX on your computer to automatically -generate images for all expression $expr$ found while composing your email. You -can also render possibly more complex sets of LaTeX, and have them inserted as -images. You can undo the LaTeX run if the formulae you inserted were wrongly -typeset, and you can customize the appearance of your formulae. +This extension runs local `latex` and `dvipng` binaries to convert `$...$` and +`$$...$$` expressions into inline images while composing HTML emails in +Thunderbird. -Since everything is run on your own computer, you're in control! +The add-on now uses the MailExtension + Experiment API model and targets +Thunderbird **140+**. -Usage +Features +-------- + +- Run LaTeX conversion on all matching expressions in a compose window +- Undo / undo all inserted images +- Insert complex LaTeX at cursor position +- Configure executable paths, template, DPI behavior, and debug/log options +- Auto-detect `latex` and `dvipng` in your PATH + +Requirements +------------ + +- Thunderbird 140 or newer +- A local TeX setup with: + - `latex` + - `dvipng` + +Build ----- -This extension is provided as a set of files. Please run make to build a working -xpi. Alternatively, you can create a file named "tblatex@xulforum.org" in your -extensions/ directory with a single line containing the path to this folder. +Run: + +```sh +make +``` + +This produces `tblatex.xpi`. + +Install +------- + +1. Build the extension: + +```sh +make +``` + +2. In Thunderbird, open `Add-ons and Themes`. +3. Click the gear icon and choose `Install Add-on From File...`. +4. Select `tblatex.xpi`. +5. Open `LaTeX It!` options and confirm `latex` / `dvipng` paths (or click autodetect). + +Development install (temporary) +------------------------------- + +1. In Thunderbird, open `Tools -> Developer Tools -> Debug Add-ons`. +2. Click `Load Temporary Add-on...`. +3. Select this repository's `manifest.json`. + +Usage Notes +----------- + +- Conversion works in **HTML compose mode**. +- In a compose window, use the `LaTeX It!` compose action menu and click + `Run LaTeX in body`. +- This converts expressions like `$\frac{2}{3}$` and + `$$\boxed{\frac{34}{31}}$$` into inline PNG images. +- Default shortcut for silent conversion: + `Ctrl+Shift+L` (`Cmd+Shift+L` on macOS). diff --git a/api/TBLatex/implementation.js b/api/TBLatex/implementation.js new file mode 100644 index 0000000..e743b4a --- /dev/null +++ b/api/TBLatex/implementation.js @@ -0,0 +1,373 @@ +"use strict"; + +const Cc = Components.classes; +const Ci = Components.interfaces; + +function initLocalFile(path) { + if (!path || typeof path !== "string") { + return null; + } + + try { + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(path); + return file; + } catch (error) { + return null; + } +} + +function fileExists(path) { + const file = initLocalFile(path); + return Boolean(file && file.exists() && file.isFile()); +} + +function createProcess(binaryFile) { + const process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess); + process.init(binaryFile); + return process; +} + +function runProcess(binaryFile, args) { + const process = createProcess(binaryFile); + process.run(true, args, args.length); + return process.exitValue; +} + +function writeUtf8TextFile(file, data) { + const outputStream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance( + Ci.nsIFileOutputStream + ); + outputStream.init(file, 0x02 | 0x08 | 0x20, 0o666, 0); + + const converter = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance( + Ci.nsIConverterOutputStream + ); + converter.init(outputStream, "UTF-8", 0, 0); + converter.writeString(data); + converter.close(); +} + +function readBinaryAsBase64(file) { + const inputStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + inputStream.init(file, 0x01, 0o444, 0); + + const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + binaryStream.setInputStream(inputStream); + const bytes = binaryStream.readBytes(binaryStream.available()); + binaryStream.close(); + inputStream.close(); + + return btoa(bytes); +} + +function removeFile(file) { + try { + if (file && file.exists()) { + file.remove(false); + } + } catch (error) { + // Ignore cleanup failures. + } +} + +function parseFontPx(fontPx, fallbackPx) { + const parsed = Number.parseFloat(fontPx); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return fallbackPx; +} + +function makeTempFiles() { + const tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + let suffix = `${Date.now()}-${Math.floor(Math.random() * 1000000)}`; + + const texFile = tempDir.clone(); + texFile.append(`tblatex-${suffix}.tex`); + while (texFile.exists()) { + suffix = `${Date.now()}-${Math.floor(Math.random() * 1000000)}`; + texFile.leafName = `tblatex-${suffix}.tex`; + } + + const dviFile = tempDir.clone(); + dviFile.append(`tblatex-${suffix}.dvi`); + + const pngFile = tempDir.clone(); + pngFile.append(`tblatex-${suffix}.png`); + + const logFile = tempDir.clone(); + logFile.append(`tblatex-${suffix}.log`); + + const auxFile = tempDir.clone(); + auxFile.append(`tblatex-${suffix}.aux`); + + return { + suffix, + tempDir, + texFile, + dviFile, + pngFile, + logFile, + auxFile, + }; +} + +function detectExecutables() { + const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + const isWindows = "@mozilla.org/windows-registry-key;1" in Cc; + + const separator = isWindows ? ";" : ":"; + const extension = isWindows ? ".exe" : ""; + + const candidates = []; + const currentPath = env.get("PATH"); + if (currentPath) { + candidates.push(...currentPath.split(separator)); + } + + if (!isWindows) { + for (const suggestion of [ + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/texbin", + "/Library/TeX/texbin", + "/usr/X11/bin", + ]) { + if (!candidates.includes(suggestion)) { + candidates.push(suggestion); + } + } + } + + let latexPath = ""; + let dvipngPath = ""; + + for (const candidate of candidates) { + const base = initLocalFile(candidate); + if (!base || !base.exists() || !base.isDirectory()) { + continue; + } + + if (!latexPath) { + const latex = base.clone(); + latex.append(`latex${extension}`); + if (latex.exists()) { + latexPath = latex.path; + } + } + + if (!dvipngPath) { + const dvipng = base.clone(); + dvipng.append(`dvipng${extension}`); + if (dvipng.exists()) { + dvipngPath = dvipng.path; + } + } + + if (latexPath && dvipngPath) { + break; + } + } + + return { latexPath, dvipngPath }; +} + +function readLegacyPrefs() { + const out = {}; + + const prefMap = [ + ["latexPath", "tblatex.latex_path", "string", ""], + ["dvipngPath", "tblatex.dvipng_path", "string", ""], + ["autodpi", "tblatex.autodpi", "bool", true], + ["fontPx", "tblatex.font_px", "int", 16], + ["log", "tblatex.log", "bool", true], + ["debug", "tblatex.debug", "bool", false], + ["keepTempFiles", "tblatex.keeptempfiles", "bool", false], + ["template", "tblatex.template", "string", ""], + ]; + + for (const [modernName, legacyName, type, fallback] of prefMap) { + if (!Services.prefs.prefHasUserValue(legacyName)) { + continue; + } + + try { + if (type === "string") { + out[modernName] = Services.prefs.getStringPref(legacyName, fallback); + } else if (type === "bool") { + out[modernName] = Services.prefs.getBoolPref(legacyName, fallback); + } else if (type === "int") { + out[modernName] = Services.prefs.getIntPref(legacyName, fallback); + } + } catch (error) { + // Ignore malformed legacy values. + } + } + + return out; +} + +function checkPreviewPackage(latexExpression) { + const pattern = + /^[^%]*\\usepackage\[(.*,\s*)?active(,.*)?\]{(.*,\s*)?preview(,.*)?}/m; + return pattern.test(latexExpression); +} + +var TBLatex = class extends ExtensionCommon.ExtensionAPI { + getAPI(context) { + return { + TBLatex: { + async render( + latexExpression, + fontPx, + fontColor, + latexPath, + dvipngPath, + autodpi, + defaultFontPx, + debug, + keepTempFiles + ) { + let status = 0; + let log = ""; + let files = null; + + try { + if (!checkPreviewPackage(latexExpression)) { + return { + status: 2, + depth: 0, + dataUrl: "", + log: + "!!! The package 'preview' (active mode) cannot be found in the LaTeX template.\n", + }; + } + + if (!fileExists(latexPath)) { + return { + status: 2, + depth: 0, + dataUrl: "", + log: + "!!! Wrong path for 'latex' executable. Set it in LaTeX It! options.\n", + }; + } + + if (!fileExists(dvipngPath)) { + return { + status: 2, + depth: 0, + dataUrl: "", + log: + "!!! Wrong path for 'dvipng' executable. Set it in LaTeX It! options.\n", + }; + } + + const latexBin = initLocalFile(latexPath); + const dvipngBin = initLocalFile(dvipngPath); + files = makeTempFiles(); + + writeUtf8TextFile(files.texFile, latexExpression); + + const latexArgs = [ + `-output-directory=${files.tempDir.path}`, + "-interaction=batchmode", + files.texFile.path, + ]; + const latexExit = runProcess(latexBin, latexArgs); + if (latexExit !== 0) { + status = 1; + log += `LaTeX process returned ${latexExit}. Proceeding anyway...\n`; + } + + if (!files.dviFile.exists()) { + return { + status: 2, + depth: 0, + dataUrl: "", + log: + log + + "!!! LaTeX did not output a .dvi file, something went wrong.\n", + }; + } + + const sizePx = autodpi + ? parseFontPx(fontPx, defaultFontPx) + : defaultFontPx; + const dpi = (sizePx * 72.27) / 10; + const safeColor = fontColor && fontColor.trim() ? fontColor : "RGB 0 0 0"; + + if (debug) { + log += `*** Using dpi=${dpi} and color=${safeColor}\n`; + } + + const dvipngArgs = [ + "-T", + "tight", + "-z", + "3", + "-bg", + "Transparent", + "-D", + String(dpi), + "-fg", + safeColor, + "-o", + files.pngFile.path, + files.dviFile.path, + ]; + const dvipngExit = runProcess(dvipngBin, dvipngArgs); + if (dvipngExit !== 0 || !files.pngFile.exists()) { + return { + status: 2, + depth: 0, + dataUrl: "", + log: + log + + `!!! dvipng failed with code ${dvipngExit}. Rendering aborted.\n`, + }; + } + + const base64Png = readBinaryAsBase64(files.pngFile); + const dataUrl = `data:image/png;base64,${base64Png}`; + + return { + status, + depth: 0, + dataUrl, + log, + }; + } catch (error) { + return { + status: 2, + depth: 0, + dataUrl: "", + log: log + `!!! Severe error while rendering LaTeX: ${String(error)}\n`, + }; + } finally { + if (files && !keepTempFiles) { + removeFile(files.texFile); + removeFile(files.auxFile); + removeFile(files.logFile); + removeFile(files.dviFile); + removeFile(files.pngFile); + } + } + }, + + async detectExecutables() { + return detectExecutables(); + }, + + async readLegacyPrefs() { + return readLegacyPrefs(); + }, + }, + }; + } +}; diff --git a/api/TBLatex/schema.json b/api/TBLatex/schema.json new file mode 100644 index 0000000..e7c6411 --- /dev/null +++ b/api/TBLatex/schema.json @@ -0,0 +1,62 @@ +[ + { + "namespace": "TBLatex", + "functions": [ + { + "name": "render", + "type": "function", + "async": true, + "parameters": [ + { + "name": "latexExpression", + "type": "string" + }, + { + "name": "fontPx", + "type": "string" + }, + { + "name": "fontColor", + "type": "string" + }, + { + "name": "latexPath", + "type": "string" + }, + { + "name": "dvipngPath", + "type": "string" + }, + { + "name": "autodpi", + "type": "boolean" + }, + { + "name": "defaultFontPx", + "type": "integer" + }, + { + "name": "debug", + "type": "boolean" + }, + { + "name": "keepTempFiles", + "type": "boolean" + } + ] + }, + { + "name": "detectExecutables", + "type": "function", + "async": true, + "parameters": [] + }, + { + "name": "readLegacyPrefs", + "type": "function", + "async": true, + "parameters": [] + } + ] + } +] diff --git a/background.js b/background.js new file mode 100644 index 0000000..8f71fc1 --- /dev/null +++ b/background.js @@ -0,0 +1,332 @@ +"use strict"; + +const DEFAULT_PREFS = { + latexPath: "", + dvipngPath: "", + autodpi: true, + fontPx: 16, + log: true, + debug: false, + keepTempFiles: false, + template: + "\\documentclass{article}\n" + + "\\usepackage[utf8]{inputenc}\n" + + "\\usepackage[active,displaymath,textmath]{preview} % DO NOT DELETE - this is required for baseline alignment\n" + + "\\pagestyle{empty}\n" + + "\\begin{document}\n" + + "__REPLACE_ME__ % this is where your LaTeX expression goes between $$\n" + + "\\end{document}\n", +}; + +const MENU_IDS = Object.freeze({ + RUN: "tblatex-run", + UNDO: "tblatex-undo", + UNDO_ALL: "tblatex-undo-all", + INSERT: "tblatex-insert-complex", + OPTIONS: "tblatex-open-options", +}); + +let composeScriptRegistration = null; + +async function getPrefs() { + const { prefs = {} } = await browser.storage.local.get("prefs"); + return { ...DEFAULT_PREFS, ...prefs }; +} + +async function setPrefs(partialPrefs) { + const current = await getPrefs(); + const next = { ...current, ...partialPrefs }; + await browser.storage.local.set({ prefs: next }); + return next; +} + +async function resetPrefs() { + await browser.storage.local.set({ prefs: { ...DEFAULT_PREFS } }); + return { ...DEFAULT_PREFS }; +} + +async function ensurePrefs() { + const current = await getPrefs(); + await browser.storage.local.set({ prefs: current }); +} + +async function migrateLegacyPrefs() { + const { legacyPrefsMigrated = false } = await browser.storage.local.get("legacyPrefsMigrated"); + if (legacyPrefsMigrated) { + return; + } + + try { + const legacyPrefs = await browser.TBLatex.readLegacyPrefs(); + if (legacyPrefs && Object.keys(legacyPrefs).length) { + await setPrefs(legacyPrefs); + } + } catch (error) { + console.warn("Legacy preference migration failed:", error); + } + + await browser.storage.local.set({ legacyPrefsMigrated: true }); +} + +async function detectAndStorePaths(force = false) { + const prefs = await getPrefs(); + const detected = await browser.TBLatex.detectExecutables(); + const updates = {}; + + if (force || !prefs.latexPath) { + updates.latexPath = detected.latexPath || ""; + } + if (force || !prefs.dvipngPath) { + updates.dvipngPath = detected.dvipngPath || ""; + } + + if (Object.keys(updates).length) { + return setPrefs(updates); + } + + return prefs; +} + +async function notify(title, message) { + try { + await browser.notifications.create({ + type: "basic", + iconUrl: "icon.png", + title, + message, + }); + } catch (error) { + console.error("Notification failed:", error); + } +} + +async function ensureComposeScriptRegistered() { + if (composeScriptRegistration) { + return composeScriptRegistration; + } + + composeScriptRegistration = browser.composeScripts.register({ + js: [{ file: "compose/compose-script.js" }], + }); + return composeScriptRegistration; +} + +async function isHtmlComposeTab(tabId) { + try { + const details = await browser.compose.getComposeDetails(tabId); + return !details.isPlainText; + } catch (error) { + return false; + } +} + +async function sendComposeCommand(tabId, payload) { + await ensureComposeScriptRegistered(); + return browser.tabs.sendMessage(tabId, payload); +} + +async function runLatexify(tabId, silent) { + if (!(await isHtmlComposeTab(tabId))) { + await notify( + "LaTeX It!", + "Cannot run LaTeX conversion in plain text compose mode." + ); + return { ok: false }; + } + + try { + return await sendComposeCommand(tabId, { command: "latexify", silent }); + } catch (error) { + console.error("Latexify failed:", error); + await notify("LaTeX It!", "Could not access compose editor for LaTeX conversion."); + return { ok: false, error: String(error) }; + } +} + +async function runUndo(tabId) { + try { + return await sendComposeCommand(tabId, { command: "undo" }); + } catch (error) { + console.error("Undo failed:", error); + return { ok: false, error: String(error) }; + } +} + +async function runUndoAll(tabId) { + try { + return await sendComposeCommand(tabId, { command: "undoAll" }); + } catch (error) { + console.error("Undo all failed:", error); + return { ok: false, error: String(error) }; + } +} + +async function openInsertDialog(tabId) { + const url = browser.runtime.getURL(`ui/insert.html?tabId=${encodeURIComponent(tabId)}`); + await browser.windows.create({ + url, + type: "popup", + width: 900, + height: 700, + }); +} + +async function withActiveComposeTab(handler) { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (!tabs.length || !tabs[0].id) { + return; + } + await handler(tabs[0]); +} + +async function handleMenuClick(menuId, tab) { + if (!tab || !tab.id) { + return; + } + + switch (menuId) { + case MENU_IDS.RUN: + await runLatexify(tab.id, false); + break; + case MENU_IDS.UNDO: + await runUndo(tab.id); + break; + case MENU_IDS.UNDO_ALL: + await runUndoAll(tab.id); + break; + case MENU_IDS.INSERT: + await openInsertDialog(tab.id); + break; + case MENU_IDS.OPTIONS: + await browser.runtime.openOptionsPage(); + break; + default: + break; + } +} + +async function setupComposeActionMenu() { + await browser.menus.removeAll(); + + browser.menus.create({ + id: MENU_IDS.RUN, + title: "Run LaTeX in body", + contexts: ["compose_action_menu"], + }); + + browser.menus.create({ + id: MENU_IDS.UNDO, + title: "Undo", + contexts: ["compose_action_menu"], + }); + + browser.menus.create({ + id: MENU_IDS.UNDO_ALL, + title: "Undo all", + contexts: ["compose_action_menu"], + }); + + browser.menus.create({ + id: MENU_IDS.INSERT, + title: "Insert complex LaTeX", + contexts: ["compose_action_menu"], + }); + + browser.menus.create({ + id: MENU_IDS.OPTIONS, + title: "Open options", + contexts: ["compose_action_menu"], + }); +} + +async function handleRuntimeMessage(message, sender) { + switch (message && message.command) { + case "getPrefs": + return getPrefs(); + case "setPrefs": + return setPrefs(message.prefs || {}); + case "resetPrefs": + return resetPrefs(); + case "autodetectPaths": + return detectAndStorePaths(true); + case "renderLatex": { + const prefs = await getPrefs(); + const autodpi = + typeof message.autodpiOverride === "boolean" + ? message.autodpiOverride + : prefs.autodpi; + const fontPx = + Number.isFinite(message.defaultFontPxOverride) && + message.defaultFontPxOverride > 0 + ? Math.round(message.defaultFontPxOverride) + : prefs.fontPx; + return browser.TBLatex.render( + message.latexExpression || "", + message.fontPx || "", + message.fontColor || "", + prefs.latexPath, + prefs.dvipngPath, + autodpi, + fontPx, + prefs.debug, + prefs.keepTempFiles + ); + } + case "openOptions": + return browser.runtime.openOptionsPage(); + case "runLatexifyFromDialog": + if (typeof message.tabId === "number") { + return runLatexify(message.tabId, Boolean(message.silent)); + } + break; + default: + break; + } + + return null; +} + +async function initialize() { + await ensurePrefs(); + await migrateLegacyPrefs(); + await detectAndStorePaths(false); + await ensureComposeScriptRegistered(); + await setupComposeActionMenu(); +} + +browser.runtime.onInstalled.addListener(async (details) => { + await initialize(); + if (details.reason === "install") { + await browser.runtime.openOptionsPage(); + } +}); + +browser.runtime.onStartup.addListener(async () => { + await initialize(); +}); + +browser.menus.onClicked.addListener(async (info, tab) => { + await handleMenuClick(info.menuItemId, tab); +}); + +browser.composeAction.onClicked.addListener(async (tab) => { + if (tab && tab.id) { + await runLatexify(tab.id, false); + } +}); + +browser.commands.onCommand.addListener(async (command) => { + if (command !== "tblatex-run-silent") { + return; + } + + await withActiveComposeTab(async (tab) => { + await runLatexify(tab.id, true); + }); +}); + +browser.runtime.onMessage.addListener((message, sender) => handleRuntimeMessage(message, sender)); + +initialize().catch((error) => { + console.error("Initialization failed:", error); +}); diff --git a/compose/compose-script.js b/compose/compose-script.js new file mode 100644 index 0000000..322bc80 --- /dev/null +++ b/compose/compose-script.js @@ -0,0 +1,404 @@ +"use strict"; + +const LOG_PANEL_ID = "tblatex-log"; +const LATEX_PATTERN = /\$\$[^\$]+\$\$|\$[^\$]+\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g; + +let undoStack = []; + +function insertAfter(nodeToInsert, referenceNode) { + const parentNode = referenceNode.parentNode; + if (!parentNode) { + return; + } + + if (referenceNode.nextSibling) { + parentNode.insertBefore(nodeToInsert, referenceNode.nextSibling); + } else { + parentNode.appendChild(nodeToInsert); + } +} + +function splitTextNodes(node) { + let latexNodes = []; + + if (node.nodeType === Node.TEXT_NODE) { + const matches = node.nodeValue.match(LATEX_PATTERN); + if (matches && matches.length) { + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]; + const start = node.nodeValue.lastIndexOf(match); + const end = start + match.length; + + const trailing = node.ownerDocument.createTextNode(node.nodeValue.slice(end)); + insertAfter(trailing, node); + + const latexNode = node.ownerDocument.createTextNode(match); + insertAfter(latexNode, node); + latexNodes.push(latexNode); + + node.nodeValue = node.nodeValue.slice(0, start); + } + } + return latexNodes; + } + + if ( + node.nodeType !== Node.ELEMENT_NODE || + node.id === LOG_PANEL_ID || + node.tagName === "SCRIPT" || + node.tagName === "STYLE" || + node.tagName === "IMG" + ) { + return latexNodes; + } + + if (node.childNodes && node.childNodes.length) { + for (let i = node.childNodes.length - 1; i >= 0; i--) { + const current = node.childNodes[i]; + const previous = i > 0 ? node.childNodes[i - 1] : null; + + if ( + previous && + previous.nodeType === Node.TEXT_NODE && + current.nodeType === Node.TEXT_NODE + ) { + previous.nodeValue += current.nodeValue; + current.nodeValue = ""; + continue; + } + + latexNodes = latexNodes.concat(splitTextNodes(current)); + } + } + + return latexNodes; +} + +function replaceMarker(template, replacement) { + const marker = "__REPLACE_ME__"; + const oldMarker = "__REPLACEME__"; + + let index = template.indexOf(marker); + let markerLength = marker.length; + + if (index < 0) { + index = template.indexOf(oldMarker); + markerLength = oldMarker.length; + } + + if (index < 0) { + const log = + "!!! Could not find the placeholder '__REPLACE_ME__' in your template.\n" + + "Please add it where your LaTeX expression should be inserted.\n"; + return [null, log]; + } + + const output = + template.slice(0, index) + replacement + template.slice(index + markerLength); + return [output, ""]; +} + +function normalizeColor(color) { + const match = color.match(/rgba?\(([^)]+)\)/i); + if (!match) { + return "RGB 0 0 0"; + } + + const parts = match[1] + .split(",") + .slice(0, 3) + .map((part) => part.trim()); + return `RGB ${parts.join(" ")}`; +} + +function getCaretElement() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return document.body; + } + + let node = selection.anchorNode; + if (!node) { + return document.body; + } + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentElement; + } + + return node && node.nodeType === Node.ELEMENT_NODE ? node : document.body; +} + +function removeLogPanel() { + const panel = document.getElementById(LOG_PANEL_ID); + if (panel && panel.parentNode) { + panel.parentNode.removeChild(panel); + } +} + +function showLogPanel(text) { + removeLogPanel(); + + const panel = document.createElement("div"); + panel.id = LOG_PANEL_ID; + panel.style.border = "1px solid #333"; + panel.style.borderRadius = "5px"; + panel.style.boxShadow = "2px 2px 6px #888"; + panel.style.margin = "1em"; + panel.style.padding = "0.5em"; + panel.style.position = "relative"; + panel.style.maxWidth = "900px"; + panel.style.background = "#fff"; + panel.style.fontFamily = "sans-serif"; + + const title = document.createElement("strong"); + title.textContent = "LaTeX It! run report"; + + const close = document.createElement("button"); + close.textContent = "X"; + close.style.position = "absolute"; + close.style.right = "6px"; + close.style.top = "6px"; + close.addEventListener("click", () => { + removeLogPanel(); + }); + + const pre = document.createElement("pre"); + pre.style.maxHeight = "400px"; + pre.style.overflow = "auto"; + pre.style.whiteSpace = "pre-wrap"; + pre.textContent = text; + + panel.appendChild(title); + panel.appendChild(close); + panel.appendChild(pre); + + if (document.body.firstChild) { + document.body.insertBefore(panel, document.body.firstChild); + } else { + document.body.appendChild(panel); + } +} + +function insertImageAtSelection(imageNode) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + document.body.appendChild(imageNode); + return; + } + + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(imageNode); + range.setStartAfter(imageNode); + range.collapse(true); + + selection.removeAllRanges(); + selection.addRange(range); +} + +async function getPrefs() { + return browser.runtime.sendMessage({ command: "getPrefs" }); +} + +async function runLatexRender(latexExpression, fontPx, fontColor, overrides = {}) { + return browser.runtime.sendMessage({ + command: "renderLatex", + latexExpression, + fontPx, + fontColor, + autodpiOverride: overrides.autodpiOverride, + defaultFontPxOverride: overrides.defaultFontPxOverride, + }); +} + +function makeImageFromResult(result, altText, titleText) { + const img = document.createElement("img"); + img.alt = altText; + img.title = titleText; + img.style.verticalAlign = `-${result.depth || 0}px`; + img.src = result.dataUrl; + return img; +} + +async function latexify({ silent }) { + const prefs = await getPrefs(); + const logs = []; + let converted = 0; + let failed = 0; + + removeLogPanel(); + + const latexNodes = splitTextNodes(document.body); + if (!latexNodes.length && !silent) { + logs.push("No unconverted LaTeX $$ expression was found."); + } + + for (const textNode of latexNodes) { + const originalText = textNode.nodeValue; + const [latexExpression, replaceLog] = replaceMarker(prefs.template, originalText); + if (replaceLog) { + logs.push(replaceLog); + failed++; + continue; + } + + const parent = textNode.parentElement || document.body; + const style = window.getComputedStyle(parent); + const fontPx = style.getPropertyValue("font-size") || `${prefs.fontPx}px`; + const fontColor = normalizeColor(style.getPropertyValue("color")); + + let renderResult; + try { + renderResult = await runLatexRender(latexExpression, fontPx, fontColor); + } catch (error) { + logs.push(`!!! Could not render "${originalText}": ${String(error)}\n`); + failed++; + continue; + } + + if (renderResult.log) { + logs.push(renderResult.log); + } + + if ((renderResult.status === 0 || renderResult.status === 1) && renderResult.dataUrl) { + const img = makeImageFromResult(renderResult, originalText, originalText); + if (textNode.parentNode) { + textNode.parentNode.insertBefore(img, textNode); + textNode.parentNode.removeChild(textNode); + undoStack.push(() => { + if (!img.parentNode) { + return; + } + img.parentNode.insertBefore(textNode, img); + img.parentNode.removeChild(img); + }); + } + converted++; + } else { + failed++; + } + } + + if (prefs.log && logs.length) { + showLogPanel(logs.join("\n")); + } + + return { + ok: true, + converted, + failed, + found: latexNodes.length, + }; +} + +function undo() { + const fn = undoStack.pop(); + if (!fn) { + return { ok: true, undone: 0 }; + } + try { + fn(); + return { ok: true, undone: 1 }; + } catch (error) { + return { ok: false, error: String(error) }; + } +} + +function undoAll() { + let count = 0; + while (undoStack.length) { + const fn = undoStack.pop(); + try { + fn(); + count++; + } catch (error) { + return { ok: false, undone: count, error: String(error) }; + } + } + return { ok: true, undone: count }; +} + +async function insertComplex({ latexExpression, autodpi, fontPx }) { + const prefs = await getPrefs(); + const logs = []; + + if (!latexExpression || !latexExpression.trim()) { + return { ok: false, error: "LaTeX expression is empty." }; + } + + removeLogPanel(); + + const element = getCaretElement(); + const style = window.getComputedStyle(element); + const resolvedFontPx = autodpi ? style.getPropertyValue("font-size") : `${fontPx}px`; + const fontColor = normalizeColor(style.getPropertyValue("color")); + + let renderResult; + try { + renderResult = await runLatexRender(latexExpression, resolvedFontPx, fontColor, { + autodpiOverride: autodpi, + defaultFontPxOverride: Number(fontPx) || 16, + }); + } catch (error) { + logs.push(`!!! Could not render complex LaTeX: ${String(error)}\n`); + if (prefs.log) { + showLogPanel(logs.join("\n")); + } + return { ok: false, error: String(error) }; + } + + if (renderResult.log) { + logs.push(renderResult.log); + } + + if ((renderResult.status === 0 || renderResult.status === 1) && renderResult.dataUrl) { + const img = makeImageFromResult(renderResult, latexExpression, latexExpression); + insertImageAtSelection(img); + undoStack.push(() => { + if (img.parentNode) { + img.parentNode.removeChild(img); + } + }); + if (prefs.log && logs.length) { + showLogPanel(logs.join("\n")); + } + return { ok: true }; + } + + if (prefs.log && logs.length) { + showLogPanel(logs.join("\n")); + } + + return { ok: false, error: "LaTeX rendering failed." }; +} + +function getSelectionText() { + const selection = window.getSelection(); + if (!selection) { + return ""; + } + return selection.toString(); +} + +browser.runtime.onMessage.addListener((message) => { + switch (message && message.command) { + case "latexify": + return latexify({ silent: Boolean(message.silent) }); + case "undo": + return Promise.resolve(undo()); + case "undoAll": + return Promise.resolve(undoAll()); + case "insertComplex": + return insertComplex({ + latexExpression: message.latexExpression || "", + autodpi: Boolean(message.autodpi), + fontPx: Number(message.fontPx) || 16, + }); + case "getSelection": + return Promise.resolve(getSelectionText()); + case "hasLogReport": + return Promise.resolve(Boolean(document.getElementById(LOG_PANEL_ID))); + default: + return null; + } +}); diff --git a/manifest.json b/manifest.json index 0cdd4b9..a1859aa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,22 +1,63 @@ { "manifest_version": 2, - "applications": { + "name": "LaTeX It!", + "description": "Automatically change $\\LaTeX$ into images in your HTML mails.", + "version": "0.8.0", + "author": "Jonathan Protzenko", + "homepage_url": "https://github.com/protz/LatexIt/wiki", + "browser_specific_settings": { "gecko": { "id": "tblatex@xulforum.org", - "strict_min_version": "68.0a1", - "strict_max_version": "73.*" + "strict_min_version": "140.0" } }, - "author": "Jonathan Protzenko", - "name": "LaTeX It!", - "description": "Automatically change $\\LaTeX$ into images in your HTML mails.", - "version": "0.7.4", - "homepage_url": "https://github.com/protz/LatexIt/wiki", - "legacy": { - "type": "xul", - "options": { - "page": "chrome://tblatex/content/options.xul", - "open_in_tab": false + "permissions": [ + "compose", + "menus", + "notifications", + "storage", + "tabs" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "compose_action": { + "default_title": "LaTeX It!", + "default_icon": "icon.png", + "type": "menu" + }, + "commands": { + "tblatex-run-silent": { + "description": "Run LaTeX It! silently in compose editor", + "suggested_key": { + "default": "Ctrl+Shift+L", + "mac": "Command+Shift+L" + } } + }, + "options_ui": { + "page": "ui/options.html", + "open_in_tab": true + }, + "experiment_apis": { + "TBLatex": { + "schema": "api/TBLatex/schema.json", + "parent": { + "scopes": [ + "addon_parent" + ], + "paths": [ + [ + "TBLatex" + ] + ], + "script": "api/TBLatex/implementation.js" + } + } + }, + "icons": { + "64": "icon.png" } } diff --git a/ui/insert.css b/ui/insert.css new file mode 100644 index 0000000..8894ea8 --- /dev/null +++ b/ui/insert.css @@ -0,0 +1,70 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font: 14px/1.4 sans-serif; + background: #f5f5f9; + color: #111; +} + +main { + max-width: 960px; + margin: 0 auto; + padding: 20px; +} + +h1 { + margin-top: 0; +} + +textarea, +input[type="number"] { + width: 100%; + border: 1px solid #bcbcc4; + border-radius: 6px; + padding: 8px; + font: inherit; +} + +textarea { + resize: vertical; +} + +.row { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.checkbox { + display: flex; + align-items: center; + gap: 8px; +} + +.actions { + display: flex; + gap: 10px; + margin-top: 12px; +} + +button { + appearance: none; + border: 1px solid #6766e8; + background: #6766e8; + color: #fff; + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; +} + +#resetTemplate { + border-color: #666; + background: #666; +} + +#status { + min-height: 1.4em; +} diff --git a/ui/insert.html b/ui/insert.html new file mode 100644 index 0000000..aa8ac73 --- /dev/null +++ b/ui/insert.html @@ -0,0 +1,40 @@ + + + + + + Insert Complex LaTeX + + + +
+

Insert Complex LaTeX

+

+ Edit the LaTeX document below. The visible result will be inserted at the + current cursor position in the compose editor. +

+ + + +
+ + +
+ +
+ + +
+ +

+
+ + + + diff --git a/ui/insert.js b/ui/insert.js new file mode 100644 index 0000000..7a30b0c --- /dev/null +++ b/ui/insert.js @@ -0,0 +1,140 @@ +"use strict"; + +const marker = "__REPLACE_ME__"; +const oldMarker = "__REPLACEME__"; + +let prefs = null; +let tabId = null; + +function setStatus(message) { + document.getElementById("status").textContent = message; +} + +function getTabIdFromUrl() { + const search = new URLSearchParams(window.location.search); + const raw = search.get("tabId"); + const parsed = Number(raw); + return Number.isInteger(parsed) ? parsed : null; +} + +function populateTemplate(template, selection) { + const textarea = document.getElementById("latexExpression"); + + let start = template.indexOf(marker); + let token = marker; + if (start < 0) { + start = template.indexOf(oldMarker); + token = oldMarker; + } + + let output = template; + if (start >= 0) { + if (selection) { + output = template.slice(0, start) + selection + template.slice(start + token.length); + textarea.value = output; + textarea.focus(); + textarea.setSelectionRange(start, start + selection.length); + return; + } + + output = + template.slice(0, start) + + "$" + + template.slice(start, start + token.length) + + "$" + + template.slice(start + token.length); + start += 1; + textarea.value = output; + textarea.focus(); + textarea.setSelectionRange(start, start + token.length); + return; + } + + textarea.value = output; +} + +async function getSelection(tabIdValue) { + if (!tabIdValue && tabIdValue !== 0) { + return ""; + } + + try { + const selection = await browser.tabs.sendMessage(tabIdValue, { command: "getSelection" }); + return typeof selection === "string" ? selection : ""; + } catch (error) { + return ""; + } +} + +async function load() { + tabId = getTabIdFromUrl(); + if (tabId === null) { + setStatus("Could not identify compose tab."); + return; + } + + prefs = await browser.runtime.sendMessage({ command: "getPrefs" }); + document.getElementById("autodpi").checked = Boolean(prefs.autodpi); + document.getElementById("fontPx").value = Number(prefs.fontPx) || 16; + + const selection = await getSelection(tabId); + populateTemplate(prefs.template, selection); +} + +function updateAutodpiUi() { + const autodpi = document.getElementById("autodpi").checked; + document.getElementById("fontPx").disabled = autodpi; +} + +async function insertExpression() { + if (tabId === null) { + setStatus("No compose tab is attached to this dialog."); + return; + } + + const latexExpression = document.getElementById("latexExpression").value; + const autodpi = document.getElementById("autodpi").checked; + const fontPx = Number(document.getElementById("fontPx").value) || 16; + + try { + const result = await browser.tabs.sendMessage(tabId, { + command: "insertComplex", + latexExpression, + autodpi, + fontPx, + }); + + if (result && result.ok) { + window.close(); + return; + } + + setStatus(`Insert failed: ${(result && result.error) || "unknown error"}`); + } catch (error) { + setStatus(`Insert failed: ${String(error)}`); + } +} + +document.getElementById("insert").addEventListener("click", () => { + insertExpression().catch((error) => { + setStatus(`Insert failed: ${String(error)}`); + }); +}); + +document.getElementById("resetTemplate").addEventListener("click", () => { + if (prefs) { + populateTemplate(prefs.template, ""); + } +}); + +document.getElementById("autodpi").addEventListener("change", () => { + updateAutodpiUi(); +}); + +load() + .then(() => { + updateAutodpiUi(); + }) + .catch((error) => { + setStatus(`Unable to initialize dialog: ${String(error)}`); + }); diff --git a/ui/options.css b/ui/options.css new file mode 100644 index 0000000..942824c --- /dev/null +++ b/ui/options.css @@ -0,0 +1,84 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font: 14px/1.4 sans-serif; + background: #f4f4f7; + color: #111; +} + +main { + max-width: 980px; + margin: 0 auto; + padding: 24px; +} + +h1 { + margin-top: 0; +} + +section { + background: #fff; + border: 1px solid #d9d9de; + border-radius: 8px; + padding: 16px; + margin-bottom: 14px; +} + +label { + display: block; + margin-bottom: 10px; +} + +label.checkbox { + display: flex; + align-items: center; + gap: 8px; +} + +input[type="text"], +input[type="number"], +textarea { + display: block; + width: 100%; + margin-top: 4px; + padding: 8px; + border: 1px solid #bcbcc4; + border-radius: 6px; + font: inherit; +} + +textarea { + resize: vertical; +} + +.row { + display: flex; + gap: 10px; +} + +.actions { + display: flex; + gap: 10px; +} + +button { + appearance: none; + border: 1px solid #6766e8; + background: #6766e8; + color: #fff; + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; +} + +button#reset { + border-color: #666; + background: #666; +} + +#status { + min-height: 1.4em; +} diff --git a/ui/options.html b/ui/options.html new file mode 100644 index 0000000..a39c6dc --- /dev/null +++ b/ui/options.html @@ -0,0 +1,72 @@ + + + + + + LaTeX It! Options + + + +
+

LaTeX It! Options

+ +
+

Executables

+ + +
+ +
+
+ +
+

Appearance

+ + +
+ +
+

Debugging

+ + + +
+ +
+

Template

+

Use __REPLACE_ME__ where your LaTeX expression should be inserted.

+ +
+ +
+ + +
+ +

+
+ + + + diff --git a/ui/options.js b/ui/options.js new file mode 100644 index 0000000..173b23a --- /dev/null +++ b/ui/options.js @@ -0,0 +1,110 @@ +"use strict"; + +const FIELDS = [ + "latexPath", + "dvipngPath", + "autodpi", + "fontPx", + "log", + "debug", + "keepTempFiles", + "template", +]; + +function setStatus(message) { + document.getElementById("status").textContent = message; +} + +function applyPrefsToForm(prefs) { + for (const name of FIELDS) { + const element = document.getElementById(name); + if (!element) { + continue; + } + + if (element.type === "checkbox") { + element.checked = Boolean(prefs[name]); + } else { + element.value = prefs[name] ?? ""; + } + } +} + +function readPrefsFromForm() { + const prefs = {}; + + for (const name of FIELDS) { + const element = document.getElementById(name); + if (!element) { + continue; + } + + if (element.type === "checkbox") { + prefs[name] = element.checked; + } else if (element.type === "number") { + const parsed = Number(element.value); + prefs[name] = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 16; + } else { + prefs[name] = element.value ?? ""; + } + } + + return prefs; +} + +async function loadPrefs() { + const prefs = await browser.runtime.sendMessage({ command: "getPrefs" }); + applyPrefsToForm(prefs); +} + +async function savePrefs() { + const prefs = readPrefsFromForm(); + await browser.runtime.sendMessage({ command: "setPrefs", prefs }); + setStatus("Options saved."); +} + +async function resetPrefs() { + const prefs = await browser.runtime.sendMessage({ command: "resetPrefs" }); + applyPrefsToForm(prefs); + setStatus("Options reset to defaults."); +} + +async function autodetect() { + const prefs = await browser.runtime.sendMessage({ command: "autodetectPaths" }); + applyPrefsToForm(prefs); + + const found = []; + if (prefs.latexPath) { + found.push("latex"); + } + if (prefs.dvipngPath) { + found.push("dvipng"); + } + if (found.length) { + setStatus(`Autodetect complete: found ${found.join(" and ")}.`); + } else { + setStatus("Autodetect did not find latex or dvipng in PATH."); + } +} + +document.getElementById("save").addEventListener("click", () => { + savePrefs().catch((error) => { + setStatus(`Save failed: ${String(error)}`); + }); +}); + +document.getElementById("reset").addEventListener("click", () => { + resetPrefs().catch((error) => { + setStatus(`Reset failed: ${String(error)}`); + }); +}); + +document.getElementById("autodetect").addEventListener("click", () => { + autodetect().catch((error) => { + setStatus(`Autodetect failed: ${String(error)}`); + }); +}); + +loadPrefs().catch((error) => { + setStatus(`Unable to load options: ${String(error)}`); +}); From fa0566a15a05e22b09eef4c70f8008b5f7c50119 Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Fri, 20 Feb 2026 00:16:13 -0800 Subject: [PATCH 02/25] Accept symlinked latex executable paths --- api/TBLatex/implementation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/TBLatex/implementation.js b/api/TBLatex/implementation.js index e743b4a..4b41d30 100644 --- a/api/TBLatex/implementation.js +++ b/api/TBLatex/implementation.js @@ -19,7 +19,7 @@ function initLocalFile(path) { function fileExists(path) { const file = initLocalFile(path); - return Boolean(file && file.exists() && file.isFile()); + return Boolean(file && file.exists()); } function createProcess(binaryFile) { From 4a520c42f5dd4256db2c066be084cde31bf588f9 Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Fri, 20 Feb 2026 00:20:17 -0800 Subject: [PATCH 03/25] Harden executable path detection and normalization --- api/TBLatex/implementation.js | 30 ++++++++++++++++++++++++++---- background.js | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/api/TBLatex/implementation.js b/api/TBLatex/implementation.js index 4b41d30..c3e2070 100644 --- a/api/TBLatex/implementation.js +++ b/api/TBLatex/implementation.js @@ -3,14 +3,30 @@ const Cc = Components.classes; const Ci = Components.interfaces; -function initLocalFile(path) { +function normalizeExecutablePath(path) { if (!path || typeof path !== "string") { + return ""; + } + + const trimmed = path.trim(); + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function initLocalFile(path) { + const normalized = normalizeExecutablePath(path); + if (!normalized) { return null; } try { const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); - file.initWithPath(path); + file.initWithPath(normalized); return file; } catch (error) { return null; @@ -132,11 +148,15 @@ function detectExecutables() { if (!isWindows) { for (const suggestion of [ + "/usr/bin", + "/bin", "/usr/local/bin", "/opt/homebrew/bin", "/usr/texbin", "/Library/TeX/texbin", "/usr/X11/bin", + "/usr/local/texlive/current/bin/x86_64-linux", + "/usr/local/texlive/current/bin/x86_64-darwin", ]) { if (!candidates.includes(suggestion)) { candidates.push(suggestion); @@ -236,6 +256,8 @@ var TBLatex = class extends ExtensionCommon.ExtensionAPI { let status = 0; let log = ""; let files = null; + latexPath = normalizeExecutablePath(latexPath); + dvipngPath = normalizeExecutablePath(dvipngPath); try { if (!checkPreviewPackage(latexExpression)) { @@ -254,7 +276,7 @@ var TBLatex = class extends ExtensionCommon.ExtensionAPI { depth: 0, dataUrl: "", log: - "!!! Wrong path for 'latex' executable. Set it in LaTeX It! options.\n", + `!!! Wrong path for 'latex' executable: "${latexPath || "(empty)"}". Set it in LaTeX It! options.\n`, }; } @@ -264,7 +286,7 @@ var TBLatex = class extends ExtensionCommon.ExtensionAPI { depth: 0, dataUrl: "", log: - "!!! Wrong path for 'dvipng' executable. Set it in LaTeX It! options.\n", + `!!! Wrong path for 'dvipng' executable: "${dvipngPath || "(empty)"}". Set it in LaTeX It! options.\n`, }; } diff --git a/background.js b/background.js index 8f71fc1..d4b681d 100644 --- a/background.js +++ b/background.js @@ -28,14 +28,40 @@ const MENU_IDS = Object.freeze({ let composeScriptRegistration = null; +function normalizeExecutablePath(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + async function getPrefs() { const { prefs = {} } = await browser.storage.local.get("prefs"); - return { ...DEFAULT_PREFS, ...prefs }; + const merged = { ...DEFAULT_PREFS, ...prefs }; + merged.latexPath = normalizeExecutablePath(merged.latexPath); + merged.dvipngPath = normalizeExecutablePath(merged.dvipngPath); + return merged; } async function setPrefs(partialPrefs) { + const sanitized = { ...partialPrefs }; + if (Object.prototype.hasOwnProperty.call(sanitized, "latexPath")) { + sanitized.latexPath = normalizeExecutablePath(sanitized.latexPath); + } + if (Object.prototype.hasOwnProperty.call(sanitized, "dvipngPath")) { + sanitized.dvipngPath = normalizeExecutablePath(sanitized.dvipngPath); + } + const current = await getPrefs(); - const next = { ...current, ...partialPrefs }; + const next = { ...current, ...sanitized }; await browser.storage.local.set({ prefs: next }); return next; } @@ -250,7 +276,10 @@ async function handleRuntimeMessage(message, sender) { case "autodetectPaths": return detectAndStorePaths(true); case "renderLatex": { - const prefs = await getPrefs(); + let prefs = await getPrefs(); + if (!prefs.latexPath || !prefs.dvipngPath) { + prefs = await detectAndStorePaths(false); + } const autodpi = typeof message.autodpiOverride === "boolean" ? message.autodpiOverride From 140594fcbf1f79c572daca5295c9f74193435680 Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Fri, 20 Feb 2026 00:23:45 -0800 Subject: [PATCH 04/25] Document and hint Snap confinement limitation --- README.md | 3 +++ api/TBLatex/implementation.js | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8915678..f3ced49 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ Requirements - A local TeX setup with: - `latex` - `dvipng` +- Important: the Thunderbird **Snap** package is sandboxed and cannot execute + host TeX binaries from `/usr/bin`. Use a non-snap Thunderbird build + (deb/tarball/manual install) for this add-on. Build ----- diff --git a/api/TBLatex/implementation.js b/api/TBLatex/implementation.js index c3e2070..74b8a2e 100644 --- a/api/TBLatex/implementation.js +++ b/api/TBLatex/implementation.js @@ -238,6 +238,15 @@ function checkPreviewPackage(latexExpression) { return pattern.test(latexExpression); } +function isSnapEnvironment() { + try { + const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + return env.exists("SNAP") || env.exists("SNAP_NAME") || env.exists("SNAP_INSTANCE_NAME"); + } catch (error) { + return false; + } +} + var TBLatex = class extends ExtensionCommon.ExtensionAPI { getAPI(context) { return { @@ -271,22 +280,28 @@ var TBLatex = class extends ExtensionCommon.ExtensionAPI { } if (!fileExists(latexPath)) { + const snapHint = isSnapEnvironment() + ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries)" + : ""; return { status: 2, depth: 0, dataUrl: "", log: - `!!! Wrong path for 'latex' executable: "${latexPath || "(empty)"}". Set it in LaTeX It! options.\n`, + `!!! Wrong path for 'latex' executable: "${latexPath || "(empty)"}". Set it in LaTeX It! options${snapHint}.\n`, }; } if (!fileExists(dvipngPath)) { + const snapHint = isSnapEnvironment() + ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries)" + : ""; return { status: 2, depth: 0, dataUrl: "", log: - `!!! Wrong path for 'dvipng' executable: "${dvipngPath || "(empty)"}". Set it in LaTeX It! options.\n`, + `!!! Wrong path for 'dvipng' executable: "${dvipngPath || "(empty)"}". Set it in LaTeX It! options${snapHint}.\n`, }; } From f12f176af514dfe3b3ad094969b0487a4c47b115 Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Fri, 20 Feb 2026 00:34:40 -0800 Subject: [PATCH 05/25] Add sandbox helper fallback for local LaTeX rendering --- Changelog | 6 + Makefile | 2 + README.md | 25 ++- api/TBLatex/implementation.js | 34 +++- api/TBLatex/schema.json | 6 + background.js | 235 ++++++++++++++++++++++--- helper/README.md | 22 +++ helper/tblatex_helper.py | 321 ++++++++++++++++++++++++++++++++++ manifest.json | 4 +- ui/options.css | 5 + ui/options.html | 17 ++ ui/options.js | 42 ++++- 12 files changed, 682 insertions(+), 37 deletions(-) create mode 100644 helper/README.md create mode 100644 helper/tblatex_helper.py diff --git a/Changelog b/Changelog index c0b8066..a9e7cdc 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,9 @@ +--v0.8.1 + +- Added automatic local-helper fallback for sandboxed Thunderbird builds (Snap/Flatpak). +- Added helper runtime detection and helper health checks in options UI. +- Added `helper/tblatex_helper.py` service for out-of-sandbox LaTeX rendering. + --v0.8.0 - Ported add-on to Thunderbird 140+ MailExtension architecture. diff --git a/Makefile b/Makefile index b87b295..aa96720 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ dist: background.js \ api \ compose \ + helper/tblatex_helper.py \ + helper/README.md \ ui \ README.md \ Changelog diff --git a/README.md b/README.md index f3ced49..75afcff 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Features - Insert complex LaTeX at cursor position - Configure executable paths, template, DPI behavior, and debug/log options - Auto-detect `latex` and `dvipng` in your PATH +- Auto-fallback to local helper service for sandboxed Thunderbird installs Requirements ------------ @@ -27,9 +28,8 @@ Requirements - A local TeX setup with: - `latex` - `dvipng` -- Important: the Thunderbird **Snap** package is sandboxed and cannot execute - host TeX binaries from `/usr/bin`. Use a non-snap Thunderbird build - (deb/tarball/manual install) for this add-on. +- For sandboxed Thunderbird (Snap/Flatpak), run the local helper service: + `python3 helper/tblatex_helper.py` Build ----- @@ -55,6 +55,8 @@ make 3. Click the gear icon and choose `Install Add-on From File...`. 4. Select `tblatex.xpi`. 5. Open `LaTeX It!` options and confirm `latex` / `dvipng` paths (or click autodetect). +6. If Thunderbird is sandboxed, enable helper fallback in options and make sure + helper URL matches your helper service. Development install (temporary) ------------------------------- @@ -73,3 +75,20 @@ Usage Notes `$$\boxed{\frac{34}{31}}$$` into inline PNG images. - Default shortcut for silent conversion: `Ctrl+Shift+L` (`Cmd+Shift+L` on macOS). + +Sandbox Fallback (Snap/Flatpak) +------------------------------- + +1. Start helper: + +```sh +python3 helper/tblatex_helper.py +``` + +2. In extension options: +- Enable `local helper fallback`. +- Set helper URL to `http://127.0.0.1:3737` (or your custom host/port). +- Click `Test helper`. + +When direct binary execution is blocked by sandboxing, LaTeX It! will +automatically route rendering through the helper. diff --git a/api/TBLatex/implementation.js b/api/TBLatex/implementation.js index 74b8a2e..5c55fce 100644 --- a/api/TBLatex/implementation.js +++ b/api/TBLatex/implementation.js @@ -238,13 +238,27 @@ function checkPreviewPackage(latexExpression) { return pattern.test(latexExpression); } -function isSnapEnvironment() { +function getRuntimeInfo() { + let sandboxed = false; + let sandboxType = "none"; + try { const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); - return env.exists("SNAP") || env.exists("SNAP_NAME") || env.exists("SNAP_INSTANCE_NAME"); + if (env.exists("SNAP") || env.exists("SNAP_NAME") || env.exists("SNAP_INSTANCE_NAME")) { + sandboxed = true; + sandboxType = "snap"; + } else if (env.exists("FLATPAK_ID")) { + sandboxed = true; + sandboxType = "flatpak"; + } } catch (error) { - return false; + // ignore } + + return { + sandboxed, + sandboxType, + }; } var TBLatex = class extends ExtensionCommon.ExtensionAPI { @@ -280,8 +294,9 @@ var TBLatex = class extends ExtensionCommon.ExtensionAPI { } if (!fileExists(latexPath)) { - const snapHint = isSnapEnvironment() - ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries)" + const runtimeInfo = getRuntimeInfo(); + const snapHint = runtimeInfo.sandboxType === "snap" + ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries directly)" : ""; return { status: 2, @@ -293,8 +308,9 @@ var TBLatex = class extends ExtensionCommon.ExtensionAPI { } if (!fileExists(dvipngPath)) { - const snapHint = isSnapEnvironment() - ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries)" + const runtimeInfo = getRuntimeInfo(); + const snapHint = runtimeInfo.sandboxType === "snap" + ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries directly)" : ""; return { status: 2, @@ -404,6 +420,10 @@ var TBLatex = class extends ExtensionCommon.ExtensionAPI { async readLegacyPrefs() { return readLegacyPrefs(); }, + + async getRuntimeInfo() { + return getRuntimeInfo(); + }, }, }; } diff --git a/api/TBLatex/schema.json b/api/TBLatex/schema.json index e7c6411..be03e74 100644 --- a/api/TBLatex/schema.json +++ b/api/TBLatex/schema.json @@ -56,6 +56,12 @@ "type": "function", "async": true, "parameters": [] + }, + { + "name": "getRuntimeInfo", + "type": "function", + "async": true, + "parameters": [] } ] } diff --git a/background.js b/background.js index d4b681d..b3c1ac7 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,8 @@ const DEFAULT_PREFS = { latexPath: "", dvipngPath: "", + helperFallbackEnabled: true, + helperUrl: "http://127.0.0.1:3737", autodpi: true, fontPx: 16, log: true, @@ -27,6 +29,12 @@ const MENU_IDS = Object.freeze({ }); let composeScriptRegistration = null; +let helperHealthCache = { + url: "", + checkedAt: 0, + ok: false, + error: "", +}; function normalizeExecutablePath(value) { if (typeof value !== "string") { @@ -43,11 +51,29 @@ function normalizeExecutablePath(value) { return trimmed; } +function normalizeHelperUrl(value) { + if (typeof value !== "string") { + return DEFAULT_PREFS.helperUrl; + } + + const trimmed = value.trim(); + if (!trimmed) { + return DEFAULT_PREFS.helperUrl; + } + + const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) + ? trimmed + : `http://${trimmed}`; + return withScheme.replace(/\/+$/, ""); +} + async function getPrefs() { const { prefs = {} } = await browser.storage.local.get("prefs"); const merged = { ...DEFAULT_PREFS, ...prefs }; merged.latexPath = normalizeExecutablePath(merged.latexPath); merged.dvipngPath = normalizeExecutablePath(merged.dvipngPath); + merged.helperUrl = normalizeHelperUrl(merged.helperUrl); + merged.helperFallbackEnabled = Boolean(merged.helperFallbackEnabled); return merged; } @@ -59,6 +85,12 @@ async function setPrefs(partialPrefs) { if (Object.prototype.hasOwnProperty.call(sanitized, "dvipngPath")) { sanitized.dvipngPath = normalizeExecutablePath(sanitized.dvipngPath); } + if (Object.prototype.hasOwnProperty.call(sanitized, "helperUrl")) { + sanitized.helperUrl = normalizeHelperUrl(sanitized.helperUrl); + } + if (Object.prototype.hasOwnProperty.call(sanitized, "helperFallbackEnabled")) { + sanitized.helperFallbackEnabled = Boolean(sanitized.helperFallbackEnabled); + } const current = await getPrefs(); const next = { ...current, ...sanitized }; @@ -126,6 +158,177 @@ async function notify(title, message) { } } +function buildTimeoutSignal(timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + return { + signal: controller.signal, + done() { + clearTimeout(timer); + }, + }; +} + +async function fetchJson(url, options = {}, timeoutMs = 5000) { + const timeout = buildTimeoutSignal(timeoutMs); + try { + const response = await fetch(url, { + ...options, + signal: timeout.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await response.json(); + } finally { + timeout.done(); + } +} + +async function checkHelperHealth(prefs, force = false) { + const url = normalizeHelperUrl(prefs.helperUrl); + const now = Date.now(); + if ( + !force && + helperHealthCache.url === url && + now - helperHealthCache.checkedAt < 10000 + ) { + return { + ok: helperHealthCache.ok, + url, + error: helperHealthCache.error, + }; + } + + try { + await fetchJson(`${url}/health`, { method: "GET" }, 1500); + helperHealthCache = { + url, + checkedAt: now, + ok: true, + error: "", + }; + } catch (error) { + helperHealthCache = { + url, + checkedAt: now, + ok: false, + error: String(error), + }; + } + + return { + ok: helperHealthCache.ok, + url, + error: helperHealthCache.error, + }; +} + +async function renderViaHelper(message, prefs, autodpi, fontPx) { + const url = normalizeHelperUrl(prefs.helperUrl); + return fetchJson( + `${url}/render`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + latexExpression: message.latexExpression || "", + fontPx: message.fontPx || "", + fontColor: message.fontColor || "", + latexPath: prefs.latexPath, + dvipngPath: prefs.dvipngPath, + autodpi, + defaultFontPx: fontPx, + debug: prefs.debug, + keepTempFiles: prefs.keepTempFiles, + }), + }, + 30000 + ); +} + +async function renderLatexMessage(message) { + let prefs = await getPrefs(); + if (!prefs.latexPath || !prefs.dvipngPath) { + prefs = await detectAndStorePaths(false); + } + + const runtimeInfo = await browser.TBLatex.getRuntimeInfo().catch(() => ({ + sandboxed: false, + sandboxType: "none", + })); + const autodpi = + typeof message.autodpiOverride === "boolean" + ? message.autodpiOverride + : prefs.autodpi; + const fontPx = + Number.isFinite(message.defaultFontPxOverride) && + message.defaultFontPxOverride > 0 + ? Math.round(message.defaultFontPxOverride) + : prefs.fontPx; + + let directResult; + try { + directResult = await browser.TBLatex.render( + message.latexExpression || "", + message.fontPx || "", + message.fontColor || "", + prefs.latexPath, + prefs.dvipngPath, + autodpi, + fontPx, + prefs.debug, + prefs.keepTempFiles + ); + } catch (error) { + directResult = { + status: 2, + depth: 0, + dataUrl: "", + log: `!!! Direct renderer failed: ${String(error)}\n`, + }; + } + + const directSucceeded = directResult && (directResult.status === 0 || directResult.status === 1); + const fallbackEnabled = prefs.helperFallbackEnabled && runtimeInfo && runtimeInfo.sandboxed; + if (directSucceeded || !fallbackEnabled) { + return directResult; + } + + const health = await checkHelperHealth(prefs, false); + if (!health.ok) { + const helperGuidance = + `\n!!! Local helper fallback is enabled but not reachable at ${health.url}.\n` + + "Start it with: python3 helper/tblatex_helper.py\n"; + return { + ...directResult, + log: `${directResult.log || ""}${helperGuidance}`, + }; + } + + try { + const helperResult = await renderViaHelper(message, prefs, autodpi, fontPx); + const helperLog = `*** Used local helper fallback (${health.url}) in ${runtimeInfo.sandboxType} sandbox mode.\n`; + return { + ...helperResult, + log: `${helperLog}${helperResult.log || ""}`, + }; + } catch (error) { + const helperErrorLog = + `\n!!! Local helper fallback failed at ${health.url}: ${String(error)}\n` + + "Ensure helper/tblatex_helper.py is running and try again.\n"; + return { + ...directResult, + log: `${directResult.log || ""}${helperErrorLog}`, + }; + } +} + async function ensureComposeScriptRegistered() { if (composeScriptRegistration) { return composeScriptRegistration; @@ -275,31 +478,13 @@ async function handleRuntimeMessage(message, sender) { return resetPrefs(); case "autodetectPaths": return detectAndStorePaths(true); - case "renderLatex": { - let prefs = await getPrefs(); - if (!prefs.latexPath || !prefs.dvipngPath) { - prefs = await detectAndStorePaths(false); - } - const autodpi = - typeof message.autodpiOverride === "boolean" - ? message.autodpiOverride - : prefs.autodpi; - const fontPx = - Number.isFinite(message.defaultFontPxOverride) && - message.defaultFontPxOverride > 0 - ? Math.round(message.defaultFontPxOverride) - : prefs.fontPx; - return browser.TBLatex.render( - message.latexExpression || "", - message.fontPx || "", - message.fontColor || "", - prefs.latexPath, - prefs.dvipngPath, - autodpi, - fontPx, - prefs.debug, - prefs.keepTempFiles - ); + case "renderLatex": + return renderLatexMessage(message || {}); + case "getRuntimeInfo": + return browser.TBLatex.getRuntimeInfo(); + case "testHelper": { + const prefs = await getPrefs(); + return checkHelperHealth(prefs, true); } case "openOptions": return browser.runtime.openOptionsPage(); diff --git a/helper/README.md b/helper/README.md new file mode 100644 index 0000000..a62f094 --- /dev/null +++ b/helper/README.md @@ -0,0 +1,22 @@ +# LaTeX It! Local Helper + +Use this helper when Thunderbird is sandboxed (for example Snap), so the +extension can render LaTeX by calling a local HTTP service outside the sandbox. + +## Run + +```sh +python3 helper/tblatex_helper.py +``` + +Default listen address: +- `http://127.0.0.1:3737` + +Optional env vars: +- `TBLATEX_HELPER_HOST` (default `127.0.0.1`) +- `TBLATEX_HELPER_PORT` (default `3737`) + +## Endpoints + +- `GET /health` +- `POST /render` diff --git a/helper/tblatex_helper.py b/helper/tblatex_helper.py new file mode 100644 index 0000000..70f330c --- /dev/null +++ b/helper/tblatex_helper.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Local LaTeX rendering helper for sandboxed Thunderbird installations. + +Starts a localhost HTTP service with: + GET /health + POST /render +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import shutil +import subprocess +import tempfile +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +PREVIEW_PACKAGE_RE = re.compile( + r"^[^%]*\\usepackage\[(.*,\s*)?active(,.*)?\]{(.*,\s*)?preview(,.*)?}", + re.MULTILINE, +) +FONT_NUMBER_RE = re.compile(r"[-+]?\d*\.?\d+") + + +def normalize_path(value: Any) -> str: + if not isinstance(value, str): + return "" + trimmed = value.strip() + if len(trimmed) >= 2 and trimmed[0] == trimmed[-1] and trimmed[0] in ("'", '"'): + trimmed = trimmed[1:-1].strip() + return trimmed + + +def select_executable(requested_path: Any, executable_name: str) -> tuple[str, str]: + requested = normalize_path(requested_path) + if requested and os.path.exists(requested) and os.access(requested, os.X_OK): + return requested, requested + auto = shutil.which(executable_name) or "" + return auto, requested + + +def parse_font_px(value: Any, fallback: int) -> float: + if isinstance(value, str): + match = FONT_NUMBER_RE.search(value) + if match: + try: + parsed = float(match.group(0)) + if parsed > 0: + return parsed + except ValueError: + pass + try: + parsed = float(value) + if parsed > 0: + return parsed + except (TypeError, ValueError): + pass + return float(fallback) + + +def run_command(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + + +def render(payload: dict[str, Any]) -> dict[str, Any]: + latex_expression = payload.get("latexExpression", "") + if not isinstance(latex_expression, str): + return { + "status": 2, + "depth": 0, + "dataUrl": "", + "log": "!!! Invalid latexExpression payload.\n", + } + + if not PREVIEW_PACKAGE_RE.search(latex_expression): + return { + "status": 2, + "depth": 0, + "dataUrl": "", + "log": "!!! The package 'preview' (active mode) cannot be found in the LaTeX template.\n", + } + + latex_path, requested_latex_path = select_executable(payload.get("latexPath"), "latex") + if not latex_path: + return { + "status": 2, + "depth": 0, + "dataUrl": "", + "log": ( + "!!! Wrong path for 'latex' executable: " + f"\"{requested_latex_path or '(auto)'}\".\n" + ), + } + + dvipng_path, requested_dvipng_path = select_executable(payload.get("dvipngPath"), "dvipng") + if not dvipng_path: + return { + "status": 2, + "depth": 0, + "dataUrl": "", + "log": ( + "!!! Wrong path for 'dvipng' executable: " + f"\"{requested_dvipng_path or '(auto)'}\".\n" + ), + } + + keep_temp_files = bool(payload.get("keepTempFiles", False)) + debug = bool(payload.get("debug", False)) + autodpi = bool(payload.get("autodpi", True)) + default_font_px_raw = payload.get("defaultFontPx", 16) + try: + default_font_px = int(default_font_px_raw) + if default_font_px <= 0: + default_font_px = 16 + except (TypeError, ValueError): + default_font_px = 16 + + font_px_value = ( + parse_font_px(payload.get("fontPx", ""), default_font_px) + if autodpi + else float(default_font_px) + ) + dpi = font_px_value * 72.27 / 10.0 + font_color = str(payload.get("fontColor", "")).strip() or "RGB 0 0 0" + + status = 0 + log_lines: list[str] = [] + + temp_dir = tempfile.mkdtemp(prefix="tblatex-helper-") + tex_file = os.path.join(temp_dir, "tblatex.tex") + dvi_file = os.path.join(temp_dir, "tblatex.dvi") + png_file = os.path.join(temp_dir, "tblatex.png") + + try: + with open(tex_file, "w", encoding="utf-8") as handle: + handle.write(latex_expression) + + latex_args = [ + latex_path, + f"-output-directory={temp_dir}", + "-interaction=batchmode", + tex_file, + ] + latex_result = run_command(latex_args) + if latex_result.returncode != 0: + status = 1 + log_lines.append( + f"LaTeX process returned {latex_result.returncode}. Proceeding anyway..." + ) + + if not os.path.exists(dvi_file): + return { + "status": 2, + "depth": 0, + "dataUrl": "", + "log": ( + "\n".join(log_lines) + + "\n!!! LaTeX did not output a .dvi file, something went wrong.\n" + ), + } + + dvipng_args = [ + dvipng_path, + "--depth", + "-T", + "tight", + "-z", + "3", + "-bg", + "Transparent", + "-D", + str(dpi), + "-fg", + font_color, + "-o", + png_file, + dvi_file, + ] + dvipng_result = run_command(dvipng_args) + if dvipng_result.returncode != 0 or not os.path.exists(png_file): + return { + "status": 2, + "depth": 0, + "dataUrl": "", + "log": ( + "\n".join(log_lines) + + f"\n!!! dvipng failed with code {dvipng_result.returncode}. Rendering aborted.\n" + ), + } + + with open(png_file, "rb") as handle: + encoded = base64.b64encode(handle.read()).decode("ascii") + + if keep_temp_files: + log_lines.append(f"Temporary files kept in {temp_dir}") + + if debug: + log_lines.append(f"*** helper latex path: {latex_path}") + log_lines.append(f"*** helper dvipng path: {dvipng_path}") + log_lines.append(f"*** helper dpi: {dpi}") + log_lines.append(f"*** helper font color: {font_color}") + + return { + "status": status, + "depth": 0, + "dataUrl": f"data:image/png;base64,{encoded}", + "log": ("\n".join(log_lines) + "\n") if log_lines else "", + } + finally: + if not keep_temp_files: + shutil.rmtree(temp_dir, ignore_errors=True) + + +class RequestHandler(BaseHTTPRequestHandler): + server_version = "tblatex-helper/0.1" + + def _send_json(self, status_code: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: # noqa: N802 + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 + route = self.path.split("?", 1)[0] + if route != "/health": + self._send_json(404, {"ok": False, "error": "not found"}) + return + + self._send_json( + 200, + { + "ok": True, + "service": "tblatex-helper", + "version": "0.1", + "latexAvailable": bool(shutil.which("latex")), + "dvipngAvailable": bool(shutil.which("dvipng")), + }, + ) + + def do_POST(self) -> None: # noqa: N802 + route = self.path.split("?", 1)[0] + if route != "/render": + self._send_json(404, {"ok": False, "error": "not found"}) + return + + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._send_json(400, {"ok": False, "error": "invalid content length"}) + return + + raw_body = self.rfile.read(content_length) + try: + payload = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self._send_json(400, {"ok": False, "error": "invalid json payload"}) + return + + try: + result = render(payload if isinstance(payload, dict) else {}) + self._send_json(200, result) + except Exception as error: # pragma: no cover + self._send_json( + 500, + { + "status": 2, + "depth": 0, + "dataUrl": "", + "log": f"!!! Helper internal error: {error}\n", + }, + ) + + def log_message(self, fmt: str, *args: Any) -> None: + print(f"[tblatex-helper] {self.address_string()} - {fmt % args}") + + +def main() -> None: + host = os.environ.get("TBLATEX_HELPER_HOST", "127.0.0.1") + port_raw = os.environ.get("TBLATEX_HELPER_PORT", "3737") + try: + port = int(port_raw) + except ValueError: + port = 3737 + + server = ThreadingHTTPServer((host, port), RequestHandler) + print(f"tblatex-helper listening on http://{host}:{port}") + print("Press Ctrl+C to stop.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopping tblatex-helper...") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/manifest.json b/manifest.json index a1859aa..5fbab09 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "LaTeX It!", "description": "Automatically change $\\LaTeX$ into images in your HTML mails.", - "version": "0.8.0", + "version": "0.8.1", "author": "Jonathan Protzenko", "homepage_url": "https://github.com/protz/LatexIt/wiki", "browser_specific_settings": { @@ -13,6 +13,8 @@ }, "permissions": [ "compose", + "http://127.0.0.1/*", + "http://localhost/*", "menus", "notifications", "storage", diff --git a/ui/options.css b/ui/options.css index 942824c..efdcf4b 100644 --- a/ui/options.css +++ b/ui/options.css @@ -82,3 +82,8 @@ button#reset { #status { min-height: 1.4em; } + +.hint { + margin: 8px 0 0 0; + color: #444; +} diff --git a/ui/options.html b/ui/options.html index a39c6dc..e47ee9d 100644 --- a/ui/options.html +++ b/ui/options.html @@ -25,6 +25,23 @@

Executables

+
+

Sandbox Helper Fallback

+

Runtime: detecting...

+ + +
+ +
+

Start helper with: python3 helper/tblatex_helper.py

+
+

Appearance

+
diff --git a/ui/options.js b/ui/options.js index f81371e..a6812e8 100644 --- a/ui/options.js +++ b/ui/options.js @@ -7,12 +7,18 @@ const FIELDS = [ "helperUrl", "autodpi", "fontPx", + "renderScale", "log", "debug", "keepTempFiles", "template", ]; +const NUMBER_DEFAULTS = Object.freeze({ + fontPx: 16, + renderScale: 4, +}); + function setStatus(message) { document.getElementById("status").textContent = message; } @@ -45,7 +51,12 @@ function readPrefsFromForm() { prefs[name] = element.checked; } else if (element.type === "number") { const parsed = Number(element.value); - prefs[name] = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 16; + const fallback = NUMBER_DEFAULTS[name] ?? 1; + let value = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; + if (name === "renderScale") { + value = Math.min(8, Math.max(1, value)); + } + prefs[name] = value; } else { prefs[name] = element.value ?? ""; } From 3667360667e8bce273594d4b0c1ee2d014607ecf Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Sun, 22 Feb 2026 00:26:09 -0800 Subject: [PATCH 20/25] Restore complex edit flow and add send-time LaTeX warning --- Changelog | 7 ++ api/TBLatex/implementation.js | 1 + background.js | 31 ++++++ compose/compose-script.js | 186 +++++++++++++++++++++++++++++-- defaults/preferences/defaults.js | 1 + manifest.json | 2 +- tblatex.xpi | Bin 23878 -> 25545 bytes ui/insert.js | 59 +++++++++- ui/options.html | 8 ++ ui/options.js | 1 + 10 files changed, 283 insertions(+), 13 deletions(-) diff --git a/Changelog b/Changelog index 3f1d0f0..c8d8c6e 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,10 @@ +--v0.8.13 + +- Added a send-time warning when unconverted LaTeX markers remain in the compose body. +- Added a setting to enable/disable the unconverted-LaTeX send warning. +- Restored editable "Insert complex LaTeX" flow by reusing the last complex input. +- Preserved complex source data on inserted images so selected formulas can be edited. + --v0.8.1 - Added automatic local-helper fallback for sandboxed Thunderbird builds (Snap/Flatpak). diff --git a/api/TBLatex/implementation.js b/api/TBLatex/implementation.js index f6269be..9fd7f55 100644 --- a/api/TBLatex/implementation.js +++ b/api/TBLatex/implementation.js @@ -301,6 +301,7 @@ function readLegacyPrefs() { ["renderScale", "tblatex.render_scale", "int", DEFAULT_RENDER_SCALE], ["log", "tblatex.log", "bool", false], ["debug", "tblatex.debug", "bool", false], + ["warnOnUnconvertedLatex", "tblatex.warn_on_unconverted", "bool", true], ["keepTempFiles", "tblatex.keeptempfiles", "bool", false], ["template", "tblatex.template", "string", ""], ]; diff --git a/background.js b/background.js index 0400504..bae5f01 100644 --- a/background.js +++ b/background.js @@ -10,6 +10,7 @@ const DEFAULT_PREFS = { renderScale: 4, log: false, debug: false, + warnOnUnconvertedLatex: true, keepTempFiles: false, template: "\\documentclass{article}\n" + @@ -105,6 +106,9 @@ async function setPrefs(partialPrefs) { if (Object.prototype.hasOwnProperty.call(sanitized, "helperFallbackEnabled")) { sanitized.helperFallbackEnabled = Boolean(sanitized.helperFallbackEnabled); } + if (Object.prototype.hasOwnProperty.call(sanitized, "warnOnUnconvertedLatex")) { + sanitized.warnOnUnconvertedLatex = Boolean(sanitized.warnOnUnconvertedLatex); + } const current = await getPrefs(); const next = { ...current, ...sanitized }; @@ -388,6 +392,23 @@ async function removeComposeRunReport(tabId) { } } +async function confirmComposeSendWithLatexCheck(tabId) { + if (!tabId) { + return true; + } + + try { + const result = await sendComposeCommand(tabId, { + command: "confirmSendWithLatexCheck", + }); + return !(result && result.okToSend === false); + } catch (error) { + // Don't block sending if the compose script is unavailable for this tab. + console.warn("Could not run send-time LaTeX check:", error); + return true; + } +} + async function runLatexify(tabId, silent) { if (latexifyInFlightByTab.get(tabId)) { return { ok: false, skipped: true, reason: "in-flight" }; @@ -587,6 +608,16 @@ if (browser.compose && browser.compose.onBeforeSend) { } await removeComposeRunReport(tab.id); + const prefs = await getPrefs(); + if (!prefs.warnOnUnconvertedLatex) { + return {}; + } + + const okToSend = await confirmComposeSendWithLatexCheck(tab.id); + if (!okToSend) { + return { cancel: true }; + } + return {}; }); } diff --git a/compose/compose-script.js b/compose/compose-script.js index af9be31..121e86d 100644 --- a/compose/compose-script.js +++ b/compose/compose-script.js @@ -4,6 +4,7 @@ const LOG_PANEL_ID = "tblatex-log"; const LATEX_PATTERN = /\$\$[^\$]+\$\$|\$[^\$]+\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g; let undoStack = []; +let lastComplexExpression = ""; function insertAfter(nodeToInsert, referenceNode) { const parentNode = referenceNode.parentNode; @@ -211,7 +212,7 @@ async function runLatexRender(latexExpression, fontPx, fontColor, overrides = {} }); } -function makeImageFromResult(result, altText, titleText) { +function makeImageFromResult(result, altText, titleText, options = {}) { const renderScale = Number(result && result.renderScale) > 0 ? Number(result.renderScale) : 1; @@ -221,6 +222,10 @@ function makeImageFromResult(result, altText, titleText) { img.title = titleText; img.style.verticalAlign = `-${depth / renderScale}px`; img.src = result.dataUrl; + if (typeof options.complexSource === "string" && options.complexSource.trim()) { + img.dataset.tblatexMode = "complex"; + img.dataset.tblatexSource = options.complexSource; + } if (renderScale > 1) { const applyDisplayScale = () => { @@ -283,6 +288,149 @@ function setCaretAfterNode(node) { } } +function getSelectedImageNode() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + + const range = selection.getRangeAt(0); + if (range.startContainer === range.endContainer && range.startContainer) { + const container = range.startContainer; + if (container.nodeType === Node.ELEMENT_NODE && range.endOffset === range.startOffset + 1) { + const selectedNode = container.childNodes[range.startOffset]; + if (selectedNode && selectedNode.nodeType === Node.ELEMENT_NODE && selectedNode.tagName === "IMG") { + return selectedNode; + } + } + } + + for (const node of [selection.anchorNode, selection.focusNode, range.commonAncestorContainer]) { + if (node && node.nodeType === Node.ELEMENT_NODE && node.tagName === "IMG") { + return node; + } + } + + return null; +} + +function normalizeLatexSnippet(snippet) { + if (typeof snippet !== "string") { + return ""; + } + return snippet.replace(/\s+/g, " ").trim(); +} + +function readComplexSourceFromImage(imageNode) { + if (!imageNode || imageNode.tagName !== "IMG") { + return ""; + } + + const dataSource = imageNode.dataset ? imageNode.dataset.tblatexSource : ""; + if (typeof dataSource === "string" && dataSource.trim()) { + return dataSource; + } + + const candidates = [imageNode.title, imageNode.alt] + .filter((value) => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + + for (const candidate of candidates) { + if ( + candidate.includes("\\documentclass") || + candidate.includes("\\begin{document}") || + candidate.includes("__REPLACE_ME__") || + candidate.includes("__REPLACEME__") + ) { + return candidate; + } + } + + if (imageNode.dataset && imageNode.dataset.tblatexMode === "complex") { + return candidates[0] || ""; + } + + return ""; +} + +function getInsertComplexSeed() { + const selectedImage = getSelectedImageNode(); + const selectedComplexSource = readComplexSourceFromImage(selectedImage); + return { + selection: getSelectionText(), + complexSource: selectedComplexSource || lastComplexExpression || "", + }; +} + +function collectUnconvertedLatex(limit = 3) { + if (!document.body) { + return { count: 0, samples: [] }; + } + + const samples = []; + let count = 0; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) { + const value = textNode.nodeValue || ""; + if (!value) { + continue; + } + + const parent = textNode.parentElement; + if (!parent) { + continue; + } + + if ( + parent.id === LOG_PANEL_ID || + parent.closest(`#${LOG_PANEL_ID}`) || + parent.tagName === "SCRIPT" || + parent.tagName === "STYLE" + ) { + continue; + } + + const matches = value.match(LATEX_PATTERN); + if (!matches || !matches.length) { + continue; + } + + count += matches.length; + for (const match of matches) { + if (samples.length >= limit) { + break; + } + const normalized = normalizeLatexSnippet(match); + if (normalized) { + samples.push(normalized); + } + } + } + + return { count, samples }; +} + +function confirmSendWithLatexCheck() { + const { count, samples } = collectUnconvertedLatex(3); + if (!count) { + return { okToSend: true, count: 0, samples: [] }; + } + + const noun = count === 1 ? "expression" : "expressions"; + const sampleText = samples.length ? `\n\nExamples:\n${samples.join("\n")}` : ""; + const message = + `LaTeX It! found ${count} unconverted LaTeX ${noun} in this message.` + + `${sampleText}\n\nSend anyway?`; + const okToSend = window.confirm(message); + + return { + okToSend, + count, + samples, + }; +} + async function latexify({ silent }) { const prefs = await getPrefs(); const logs = []; @@ -419,13 +567,33 @@ async function insertComplex({ latexExpression, autodpi, fontPx }) { } if ((renderResult.status === 0 || renderResult.status === 1) && renderResult.dataUrl) { - const img = makeImageFromResult(renderResult, latexExpression, latexExpression); - insertImageAtSelection(img); - undoStack.push(() => { - if (img.parentNode) { - img.parentNode.removeChild(img); - } + const selectedImage = getSelectedImageNode(); + const img = makeImageFromResult(renderResult, latexExpression, latexExpression, { + complexSource: latexExpression, }); + + if (selectedImage && selectedImage.parentNode) { + selectedImage.parentNode.insertBefore(img, selectedImage); + selectedImage.parentNode.removeChild(selectedImage); + setCaretAfterNode(img); + undoStack.push(() => { + if (!img.parentNode) { + return; + } + img.parentNode.insertBefore(selectedImage, img); + img.parentNode.removeChild(img); + setCaretAfterNode(selectedImage); + }); + } else { + insertImageAtSelection(img); + undoStack.push(() => { + if (img.parentNode) { + img.parentNode.removeChild(img); + } + }); + } + + lastComplexExpression = latexExpression; if (prefs.log && logs.length) { showLogPanel(logs.join("\n")); } @@ -463,6 +631,8 @@ browser.runtime.onMessage.addListener((message) => { }); case "getSelection": return Promise.resolve(getSelectionText()); + case "getInsertComplexSeed": + return Promise.resolve(getInsertComplexSeed()); case "hasLogReport": return Promise.resolve(Boolean(document.getElementById(LOG_PANEL_ID))); case "removeLogReport": { @@ -470,6 +640,8 @@ browser.runtime.onMessage.addListener((message) => { removeLogPanel(); return Promise.resolve({ ok: true, removed: hadReport }); } + case "confirmSendWithLatexCheck": + return Promise.resolve(confirmSendWithLatexCheck()); default: return null; } diff --git a/defaults/preferences/defaults.js b/defaults/preferences/defaults.js index db971cd..57c58b1 100644 --- a/defaults/preferences/defaults.js +++ b/defaults/preferences/defaults.js @@ -5,6 +5,7 @@ pref("tblatex.font_px", 16); pref("tblatex.render_scale", 4); pref("tblatex.log", false); pref("tblatex.debug", false); +pref("tblatex.warn_on_unconverted", true); pref("tblatex.keeptempfiles", false); pref("tblatex.template", "\\documentclass{article}\n\\usepackage[utf8]{inputenc}\n\\usepackage[active,displaymath,textmath]{preview} % DO NOT DELETE - this is required for baseline alignment\n\\pagestyle{empty}\n\\begin{document}\n__REPLACE_ME__ % this is where your LaTeX expression goes between $$\n\\end{document}\n"); pref("tblatex.firstrun", 0); diff --git a/manifest.json b/manifest.json index 22ae23d..06c6133 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "LaTeX It!", "description": "Automatically change $\\LaTeX$ into images in your HTML mails.", - "version": "0.8.12", + "version": "0.8.13", "author": "Jonathan Protzenko", "homepage_url": "https://github.com/protz/LatexIt/wiki", "applications": { diff --git a/tblatex.xpi b/tblatex.xpi index eb3753f13be61f4d927746d1d5e56c0a9bd5b0c4..eca8ee6fe93e852541acf504cdfe92dfb11d3837 100644 GIT binary patch delta 20757 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 diff --git a/ui/insert.js b/ui/insert.js index 7a30b0c..7df1bb5 100644 --- a/ui/insert.js +++ b/ui/insert.js @@ -55,17 +55,62 @@ function populateTemplate(template, selection) { async function getSelection(tabIdValue) { if (!tabIdValue && tabIdValue !== 0) { - return ""; + return { + selection: "", + complexSource: "", + }; + } + + try { + const seed = await browser.tabs.sendMessage(tabIdValue, { + command: "getInsertComplexSeed", + }); + + if (seed && typeof seed === "object") { + return { + selection: typeof seed.selection === "string" ? seed.selection : "", + complexSource: typeof seed.complexSource === "string" ? seed.complexSource : "", + }; + } + } catch (error) { + // Fall back to legacy selection command below. } try { const selection = await browser.tabs.sendMessage(tabIdValue, { command: "getSelection" }); - return typeof selection === "string" ? selection : ""; + return { + selection: typeof selection === "string" ? selection : "", + complexSource: "", + }; } catch (error) { - return ""; + return { + selection: "", + complexSource: "", + }; } } +function showComplexSource(source) { + const textarea = document.getElementById("latexExpression"); + textarea.value = source; + textarea.focus(); + + let start = source.indexOf(marker); + let length = marker.length; + if (start < 0) { + start = source.indexOf(oldMarker); + length = oldMarker.length; + } + + if (start >= 0) { + textarea.setSelectionRange(start, start + length); + return; + } + + const end = source.length; + textarea.setSelectionRange(end, end); +} + async function load() { tabId = getTabIdFromUrl(); if (tabId === null) { @@ -77,8 +122,12 @@ async function load() { document.getElementById("autodpi").checked = Boolean(prefs.autodpi); document.getElementById("fontPx").value = Number(prefs.fontPx) || 16; - const selection = await getSelection(tabId); - populateTemplate(prefs.template, selection); + const seed = await getSelection(tabId); + if (seed.complexSource) { + showComplexSource(seed.complexSource); + } else { + populateTemplate(prefs.template, seed.selection); + } } function updateAutodpiUi() { diff --git a/ui/options.html b/ui/options.html index 9110126..3b967f4 100644 --- a/ui/options.html +++ b/ui/options.html @@ -74,6 +74,14 @@

Debugging

+
+

Sending

+ +
+

Template

Use __REPLACE_ME__ where your LaTeX expression should be inserted.

diff --git a/ui/options.js b/ui/options.js index a6812e8..78c4281 100644 --- a/ui/options.js +++ b/ui/options.js @@ -10,6 +10,7 @@ const FIELDS = [ "renderScale", "log", "debug", + "warnOnUnconvertedLatex", "keepTempFiles", "template", ]; From ed8551cd60006ffeda8b717544f42b9754f7feed Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Sun, 22 Feb 2026 00:49:13 -0800 Subject: [PATCH 21/25] Add robust inline and complex formula edit-after-insert --- Changelog | 7 ++ compose/compose-script.js | 229 +++++++++++++++++++++++++++++++++----- manifest.json | 2 +- tblatex.xpi | Bin 25545 -> 26787 bytes ui/insert.html | 4 + ui/insert.js | 20 +++- 6 files changed, 231 insertions(+), 31 deletions(-) diff --git a/Changelog b/Changelog index c8d8c6e..dad4de7 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,10 @@ +--v0.8.14 + +- Added edit-after-insert support for inline LaTeX formulas rendered from body markers. +- Improved formula-source persistence by storing mode, expression, and full document metadata. +- Added resilient edit-source recovery from image data attributes with alt/title fallback. +- Improved insert dialog behavior to preload selected formula source for in-place replacement. + --v0.8.13 - Added a send-time warning when unconverted LaTeX markers remain in the compose body. diff --git a/compose/compose-script.js b/compose/compose-script.js index 121e86d..32815c2 100644 --- a/compose/compose-script.js +++ b/compose/compose-script.js @@ -2,6 +2,7 @@ const LOG_PANEL_ID = "tblatex-log"; const LATEX_PATTERN = /\$\$[^\$]+\$\$|\$[^\$]+\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g; +const INLINE_LATEX_EXACT_PATTERN = /^(?:\$\$[^\$]+\$\$|\$[^\$]+\$|\\\[[\s\S]*\\\]|\\\([\s\S]*\\\))$/; let undoStack = []; let lastComplexExpression = ""; @@ -75,7 +76,7 @@ function splitTextNodes(node) { return latexNodes; } -function replaceMarker(template, replacement) { +function findTemplateMarker(template) { const marker = "__REPLACE_ME__"; const oldMarker = "__REPLACEME__"; @@ -88,17 +89,48 @@ function replaceMarker(template, replacement) { } if (index < 0) { + return null; + } + + return { index, markerLength }; +} + +function replaceMarker(template, replacement) { + const markerInfo = findTemplateMarker(template); + + if (!markerInfo) { const log = "!!! Could not find the placeholder '__REPLACE_ME__' in your template.\n" + "Please add it where your LaTeX expression should be inserted.\n"; return [null, log]; } + const { index, markerLength } = markerInfo; const output = template.slice(0, index) + replacement + template.slice(index + markerLength); return [output, ""]; } +function extractExpressionFromTemplate(template, latexDocument) { + if (typeof template !== "string" || typeof latexDocument !== "string") { + return ""; + } + + const markerInfo = findTemplateMarker(template); + if (!markerInfo) { + return ""; + } + + const prefix = template.slice(0, markerInfo.index); + const suffix = template.slice(markerInfo.index + markerInfo.markerLength); + if (!latexDocument.startsWith(prefix) || !latexDocument.endsWith(suffix)) { + return ""; + } + + const expression = latexDocument.slice(prefix.length, latexDocument.length - suffix.length); + return INLINE_LATEX_EXACT_PATTERN.test(expression.trim()) ? expression : ""; +} + function normalizeColor(color) { const match = color.match(/rgba?\(([^)]+)\)/i); if (!match) { @@ -212,6 +244,69 @@ async function runLatexRender(latexExpression, fontPx, fontColor, overrides = {} }); } +function normalizeSourceValue(value) { + if (typeof value !== "string") { + return ""; + } + return value.trim() ? value : ""; +} + +function isLikelyLatexDocument(text) { + if (typeof text !== "string") { + return false; + } + + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + + return ( + trimmed.includes("\\documentclass") || + trimmed.includes("\\begin{document}") || + trimmed.includes("\\end{document}") || + trimmed.includes("__REPLACE_ME__") || + trimmed.includes("__REPLACEME__") + ); +} + +function isInlineLatexExpression(text) { + return typeof text === "string" && INLINE_LATEX_EXACT_PATTERN.test(text.trim()); +} + +function applyFormulaMetadata(img, options = {}) { + const legacyComplexSource = normalizeSourceValue(options.complexSource); + const sourceDocument = normalizeSourceValue( + options.sourceDocument || legacyComplexSource + ); + const sourceExpression = normalizeSourceValue(options.sourceExpression); + let sourceMode = normalizeSourceValue(options.sourceMode); + + if (sourceMode !== "inline" && sourceMode !== "complex") { + if (sourceExpression && isInlineLatexExpression(sourceExpression)) { + sourceMode = "inline"; + } else if (sourceDocument) { + sourceMode = isInlineLatexExpression(sourceDocument) ? "inline" : "complex"; + } else { + sourceMode = ""; + } + } + + if (sourceMode) { + img.dataset.tblatexMode = sourceMode; + } + if (sourceDocument) { + img.dataset.tblatexDoc = sourceDocument; + } + if (sourceExpression) { + img.dataset.tblatexExpr = sourceExpression; + } + if (legacyComplexSource) { + // Compatibility with early 0.8.x builds that only used this single field. + img.dataset.tblatexSource = legacyComplexSource; + } +} + function makeImageFromResult(result, altText, titleText, options = {}) { const renderScale = Number(result && result.renderScale) > 0 ? Number(result.renderScale) @@ -222,10 +317,7 @@ function makeImageFromResult(result, altText, titleText, options = {}) { img.title = titleText; img.style.verticalAlign = `-${depth / renderScale}px`; img.src = result.dataUrl; - if (typeof options.complexSource === "string" && options.complexSource.trim()) { - img.dataset.tblatexMode = "complex"; - img.dataset.tblatexSource = options.complexSource; - } + applyFormulaMetadata(img, options); if (renderScale > 1) { const applyDisplayScale = () => { @@ -321,45 +413,113 @@ function normalizeLatexSnippet(snippet) { return snippet.replace(/\s+/g, " ").trim(); } -function readComplexSourceFromImage(imageNode) { +function getImageDataField(imageNode, datasetKey, attributeName) { if (!imageNode || imageNode.tagName !== "IMG") { return ""; } - const dataSource = imageNode.dataset ? imageNode.dataset.tblatexSource : ""; - if (typeof dataSource === "string" && dataSource.trim()) { - return dataSource; + const datasetValue = imageNode.dataset ? imageNode.dataset[datasetKey] : ""; + if (typeof datasetValue === "string" && datasetValue.trim()) { + return datasetValue; } - const candidates = [imageNode.title, imageNode.alt] - .filter((value) => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean); + const attributeValue = imageNode.getAttribute(attributeName); + if (typeof attributeValue === "string" && attributeValue.trim()) { + return attributeValue; + } - for (const candidate of candidates) { - if ( - candidate.includes("\\documentclass") || - candidate.includes("\\begin{document}") || - candidate.includes("__REPLACE_ME__") || - candidate.includes("__REPLACEME__") - ) { - return candidate; + return ""; +} + +function pushUniqueCandidate(candidates, value) { + if (typeof value !== "string" || !value.trim()) { + return; + } + if (!candidates.includes(value)) { + candidates.push(value); + } +} + +function readFormulaSeedFromImage(imageNode) { + if (!imageNode || imageNode.tagName !== "IMG") { + return null; + } + + const candidates = []; + + const rawMode = getImageDataField(imageNode, "tblatexMode", "data-tblatex-mode"); + const rawDocument = getImageDataField(imageNode, "tblatexDoc", "data-tblatex-doc"); + const rawExpression = getImageDataField(imageNode, "tblatexExpr", "data-tblatex-expr"); + const legacySource = getImageDataField(imageNode, "tblatexSource", "data-tblatex-source"); + + pushUniqueCandidate(candidates, rawDocument); + pushUniqueCandidate(candidates, rawExpression); + pushUniqueCandidate(candidates, legacySource); + pushUniqueCandidate(candidates, imageNode.title || ""); + pushUniqueCandidate(candidates, imageNode.alt || ""); + + let sourceDocument = normalizeSourceValue(rawDocument); + let sourceExpression = normalizeSourceValue(rawExpression); + let sourceMode = rawMode === "inline" || rawMode === "complex" ? rawMode : ""; + + if (!sourceDocument) { + for (const candidate of candidates) { + if (isLikelyLatexDocument(candidate)) { + sourceDocument = candidate; + break; + } } } - if (imageNode.dataset && imageNode.dataset.tblatexMode === "complex") { - return candidates[0] || ""; + if (!sourceExpression) { + for (const candidate of candidates) { + if (isInlineLatexExpression(candidate)) { + sourceExpression = candidate; + break; + } + } } - return ""; + if (!sourceMode) { + if (sourceExpression) { + sourceMode = "inline"; + } else if (sourceDocument) { + sourceMode = isInlineLatexExpression(sourceDocument) ? "inline" : "complex"; + } else if (legacySource) { + sourceMode = isInlineLatexExpression(legacySource) ? "inline" : "complex"; + } + } + + if (sourceMode === "inline" && !sourceExpression && sourceDocument && isInlineLatexExpression(sourceDocument)) { + sourceExpression = sourceDocument; + } + + if (!sourceDocument && !sourceExpression) { + return null; + } + + return { + sourceMode, + sourceDocument, + sourceExpression, + }; } function getInsertComplexSeed() { const selectedImage = getSelectedImageNode(); - const selectedComplexSource = readComplexSourceFromImage(selectedImage); + const selectedSeed = readFormulaSeedFromImage(selectedImage); + const sourceDocument = selectedSeed + ? selectedSeed.sourceDocument || "" + : lastComplexExpression || ""; + const sourceExpression = selectedSeed ? selectedSeed.sourceExpression || "" : ""; + const sourceMode = selectedSeed ? selectedSeed.sourceMode || "" : ""; + return { selection: getSelectionText(), - complexSource: selectedComplexSource || lastComplexExpression || "", + sourceMode, + sourceExpression, + sourceDocument, + complexSource: sourceDocument, }; } @@ -472,7 +632,11 @@ async function latexify({ silent }) { } if ((renderResult.status === 0 || renderResult.status === 1) && renderResult.dataUrl) { - const img = makeImageFromResult(renderResult, originalText, originalText); + const img = makeImageFromResult(renderResult, originalText, originalText, { + sourceMode: "inline", + sourceExpression: originalText, + sourceDocument: latexExpression, + }); if (textNode.parentNode) { const movedCaret = moveCaretAwayFromTextNode(textNode); textNode.parentNode.insertBefore(img, textNode); @@ -568,7 +732,16 @@ async function insertComplex({ latexExpression, autodpi, fontPx }) { if ((renderResult.status === 0 || renderResult.status === 1) && renderResult.dataUrl) { const selectedImage = getSelectedImageNode(); - const img = makeImageFromResult(renderResult, latexExpression, latexExpression, { + const extractedInlineExpression = extractExpressionFromTemplate( + prefs.template, + latexExpression + ); + const sourceMode = extractedInlineExpression ? "inline" : "complex"; + const accessibleText = extractedInlineExpression || latexExpression; + const img = makeImageFromResult(renderResult, accessibleText, accessibleText, { + sourceMode, + sourceExpression: extractedInlineExpression, + sourceDocument: latexExpression, complexSource: latexExpression, }); diff --git a/manifest.json b/manifest.json index 06c6133..e21eb62 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "LaTeX It!", "description": "Automatically change $\\LaTeX$ into images in your HTML mails.", - "version": "0.8.13", + "version": "0.8.14", "author": "Jonathan Protzenko", "homepage_url": "https://github.com/protz/LatexIt/wiki", "applications": { diff --git a/tblatex.xpi b/tblatex.xpi index eca8ee6fe93e852541acf504cdfe92dfb11d3837..cf257b60ccc2a4c60974137c4b20f5f1e9605308 100644 GIT binary patch delta 11730 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 D=^Jnz delta 10479 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 diff --git a/ui/insert.html b/ui/insert.html index aa8ac73..0f9d37a 100644 --- a/ui/insert.html +++ b/ui/insert.html @@ -13,6 +13,10 @@

Insert Complex LaTeX

Edit the LaTeX document below. The visible result will be inserted at the current cursor position in the compose editor.

+

+ If a LaTeX image is selected in the compose body, this dialog edits and + replaces that formula in place. +

diff --git a/ui/insert.js b/ui/insert.js index 7df1bb5..215037a 100644 --- a/ui/insert.js +++ b/ui/insert.js @@ -57,6 +57,9 @@ async function getSelection(tabIdValue) { if (!tabIdValue && tabIdValue !== 0) { return { selection: "", + sourceMode: "", + sourceExpression: "", + sourceDocument: "", complexSource: "", }; } @@ -69,6 +72,10 @@ async function getSelection(tabIdValue) { if (seed && typeof seed === "object") { return { selection: typeof seed.selection === "string" ? seed.selection : "", + sourceMode: typeof seed.sourceMode === "string" ? seed.sourceMode : "", + sourceExpression: + typeof seed.sourceExpression === "string" ? seed.sourceExpression : "", + sourceDocument: typeof seed.sourceDocument === "string" ? seed.sourceDocument : "", complexSource: typeof seed.complexSource === "string" ? seed.complexSource : "", }; } @@ -80,11 +87,17 @@ async function getSelection(tabIdValue) { const selection = await browser.tabs.sendMessage(tabIdValue, { command: "getSelection" }); return { selection: typeof selection === "string" ? selection : "", + sourceMode: "", + sourceExpression: "", + sourceDocument: "", complexSource: "", }; } catch (error) { return { selection: "", + sourceMode: "", + sourceExpression: "", + sourceDocument: "", complexSource: "", }; } @@ -123,8 +136,11 @@ async function load() { document.getElementById("fontPx").value = Number(prefs.fontPx) || 16; const seed = await getSelection(tabId); - if (seed.complexSource) { - showComplexSource(seed.complexSource); + const sourceDocument = seed.sourceDocument || seed.complexSource || ""; + if (sourceDocument) { + showComplexSource(sourceDocument); + } else if (seed.sourceExpression) { + populateTemplate(prefs.template, seed.sourceExpression); } else { populateTemplate(prefs.template, seed.selection); } From 1aa36c298078494cd6fc99191c028ffa00addcd7 Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Sun, 22 Feb 2026 01:01:12 -0800 Subject: [PATCH 22/25] Add recent formula history picker in insert dialog --- Changelog | 7 ++ compose/compose-script.js | 92 ++++++++++++++++++++++++-- manifest.json | 2 +- tblatex.xpi | Bin 26787 -> 28195 bytes ui/insert.css | 16 ++++- ui/insert.html | 9 +++ ui/insert.js | 135 +++++++++++++++++++++++++++++++++++--- 7 files changed, 246 insertions(+), 15 deletions(-) diff --git a/Changelog b/Changelog index dad4de7..d01c601 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,10 @@ +--v0.8.15 + +- Added a recent-formulas history list in the Insert Complex LaTeX dialog. +- Added load/refresh actions to reuse and edit previously rendered formulas. +- Tracks both inline and complex formulas in compose-session history with de-duplication. +- Keeps robust metadata recovery for formulas selected from the message body. + --v0.8.14 - Added edit-after-insert support for inline LaTeX formulas rendered from body markers. diff --git a/compose/compose-script.js b/compose/compose-script.js index 32815c2..046add0 100644 --- a/compose/compose-script.js +++ b/compose/compose-script.js @@ -3,9 +3,11 @@ const LOG_PANEL_ID = "tblatex-log"; const LATEX_PATTERN = /\$\$[^\$]+\$\$|\$[^\$]+\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g; const INLINE_LATEX_EXACT_PATTERN = /^(?:\$\$[^\$]+\$\$|\$[^\$]+\$|\\\[[\s\S]*\\\]|\\\([\s\S]*\\\))$/; +const FORMULA_HISTORY_LIMIT = 50; let undoStack = []; let lastComplexExpression = ""; +let formulaHistory = []; function insertAfter(nodeToInsert, referenceNode) { const parentNode = referenceNode.parentNode; @@ -307,6 +309,79 @@ function applyFormulaMetadata(img, options = {}) { } } +function summarizeFormulaPreview(text) { + const normalized = normalizeLatexSnippet(text || ""); + if (!normalized) { + return ""; + } + if (normalized.length <= 120) { + return normalized; + } + return `${normalized.slice(0, 117)}...`; +} + +function normalizeFormulaSeed(seed) { + if (!seed || typeof seed !== "object") { + return null; + } + + const sourceDocument = normalizeSourceValue(seed.sourceDocument); + const sourceExpression = normalizeSourceValue(seed.sourceExpression); + let sourceMode = normalizeSourceValue(seed.sourceMode); + + if (!sourceDocument && !sourceExpression) { + return null; + } + + if (sourceMode !== "inline" && sourceMode !== "complex") { + if (sourceExpression) { + sourceMode = "inline"; + } else { + sourceMode = isInlineLatexExpression(sourceDocument) ? "inline" : "complex"; + } + } + + const preview = summarizeFormulaPreview(sourceExpression || sourceDocument); + const dedupeKey = sourceDocument || sourceExpression; + return { + sourceMode, + sourceExpression, + sourceDocument, + preview, + dedupeKey, + }; +} + +function addFormulaToHistory(seed) { + const normalizedSeed = normalizeFormulaSeed(seed); + if (!normalizedSeed) { + return; + } + + formulaHistory = formulaHistory.filter( + (item) => item.dedupeKey !== normalizedSeed.dedupeKey + ); + formulaHistory.unshift({ + ...normalizedSeed, + savedAt: Date.now(), + }); + + if (formulaHistory.length > FORMULA_HISTORY_LIMIT) { + formulaHistory.length = FORMULA_HISTORY_LIMIT; + } +} + +function getFormulaHistory() { + return formulaHistory.map((item, index) => ({ + id: String(index), + sourceMode: item.sourceMode, + sourceExpression: item.sourceExpression, + sourceDocument: item.sourceDocument, + preview: item.preview, + savedAt: item.savedAt, + })); +} + function makeImageFromResult(result, altText, titleText, options = {}) { const renderScale = Number(result && result.renderScale) > 0 ? Number(result.renderScale) @@ -508,6 +583,9 @@ function readFormulaSeedFromImage(imageNode) { function getInsertComplexSeed() { const selectedImage = getSelectedImageNode(); const selectedSeed = readFormulaSeedFromImage(selectedImage); + if (selectedSeed) { + addFormulaToHistory(selectedSeed); + } const sourceDocument = selectedSeed ? selectedSeed.sourceDocument || "" : lastComplexExpression || ""; @@ -632,11 +710,12 @@ async function latexify({ silent }) { } if ((renderResult.status === 0 || renderResult.status === 1) && renderResult.dataUrl) { - const img = makeImageFromResult(renderResult, originalText, originalText, { + const formulaSeed = { sourceMode: "inline", sourceExpression: originalText, sourceDocument: latexExpression, - }); + }; + const img = makeImageFromResult(renderResult, originalText, originalText, formulaSeed); if (textNode.parentNode) { const movedCaret = moveCaretAwayFromTextNode(textNode); textNode.parentNode.insertBefore(img, textNode); @@ -652,6 +731,7 @@ async function latexify({ silent }) { img.parentNode.removeChild(img); }); } + addFormulaToHistory(formulaSeed); converted++; } else { failed++; @@ -738,12 +818,13 @@ async function insertComplex({ latexExpression, autodpi, fontPx }) { ); const sourceMode = extractedInlineExpression ? "inline" : "complex"; const accessibleText = extractedInlineExpression || latexExpression; - const img = makeImageFromResult(renderResult, accessibleText, accessibleText, { + const formulaSeed = { sourceMode, sourceExpression: extractedInlineExpression, sourceDocument: latexExpression, complexSource: latexExpression, - }); + }; + const img = makeImageFromResult(renderResult, accessibleText, accessibleText, formulaSeed); if (selectedImage && selectedImage.parentNode) { selectedImage.parentNode.insertBefore(img, selectedImage); @@ -766,6 +847,7 @@ async function insertComplex({ latexExpression, autodpi, fontPx }) { }); } + addFormulaToHistory(formulaSeed); lastComplexExpression = latexExpression; if (prefs.log && logs.length) { showLogPanel(logs.join("\n")); @@ -806,6 +888,8 @@ browser.runtime.onMessage.addListener((message) => { return Promise.resolve(getSelectionText()); case "getInsertComplexSeed": return Promise.resolve(getInsertComplexSeed()); + case "getFormulaHistory": + return Promise.resolve(getFormulaHistory()); case "hasLogReport": return Promise.resolve(Boolean(document.getElementById(LOG_PANEL_ID))); case "removeLogReport": { diff --git a/manifest.json b/manifest.json index e21eb62..e39278b 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "LaTeX It!", "description": "Automatically change $\\LaTeX$ into images in your HTML mails.", - "version": "0.8.14", + "version": "0.8.15", "author": "Jonathan Protzenko", "homepage_url": "https://github.com/protz/LatexIt/wiki", "applications": { diff --git a/tblatex.xpi b/tblatex.xpi index cf257b60ccc2a4c60974137c4b20f5f1e9605308..ce536eb1284630ff0c41b40cb708546d420ce11b 100644 GIT binary patch delta 13306 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 delta 11885 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 B56b`m diff --git a/ui/insert.css b/ui/insert.css index 8894ea8..cc5b920 100644 --- a/ui/insert.css +++ b/ui/insert.css @@ -20,7 +20,8 @@ h1 { } textarea, -input[type="number"] { +input[type="number"], +select { width: 100%; border: 1px solid #bcbcc4; border-radius: 6px; @@ -32,6 +33,14 @@ textarea { resize: vertical; } +.history { + margin: 12px 0; +} + +#formulaHistory { + min-height: 8.5em; +} + .row { display: grid; gap: 12px; @@ -65,6 +74,11 @@ button { background: #666; } +#refreshHistory { + border-color: #666; + background: #666; +} + #status { min-height: 1.4em; } diff --git a/ui/insert.html b/ui/insert.html index 0f9d37a..38b0372 100644 --- a/ui/insert.html +++ b/ui/insert.html @@ -18,6 +18,15 @@

Insert Complex LaTeX

replaces that formula in place.

+
+ + +
+ + +
+
+
diff --git a/ui/insert.js b/ui/insert.js index 215037a..04fac63 100644 --- a/ui/insert.js +++ b/ui/insert.js @@ -5,6 +5,7 @@ const oldMarker = "__REPLACEME__"; let prefs = null; let tabId = null; +let formulaHistory = []; function setStatus(message) { document.getElementById("status").textContent = message; @@ -103,6 +104,109 @@ async function getSelection(tabIdValue) { } } +async function getFormulaHistory(tabIdValue) { + if (!tabIdValue && tabIdValue !== 0) { + return []; + } + + try { + const history = await browser.tabs.sendMessage(tabIdValue, { + command: "getFormulaHistory", + }); + return Array.isArray(history) ? history : []; + } catch (error) { + return []; + } +} + +function truncatePreview(value) { + if (typeof value !== "string") { + return ""; + } + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= 110) { + return normalized; + } + return `${normalized.slice(0, 107)}...`; +} + +function formatHistoryItem(item) { + const mode = item && item.sourceMode === "inline" ? "Inline" : "Complex"; + const rawPreview = + (item && item.preview) || + (item && item.sourceExpression) || + (item && item.sourceDocument) || + ""; + const preview = truncatePreview(rawPreview) || "(empty)"; + return `[${mode}] ${preview}`; +} + +function renderFormulaHistory() { + const list = document.getElementById("formulaHistory"); + const loadButton = document.getElementById("loadHistory"); + + while (list.firstChild) { + list.removeChild(list.firstChild); + } + + formulaHistory.forEach((item, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = formatHistoryItem(item); + list.appendChild(option); + }); + + const hasItems = formulaHistory.length > 0; + list.disabled = !hasItems; + loadButton.disabled = !hasItems; + if (hasItems) { + list.selectedIndex = 0; + } +} + +function getSelectedHistoryEntry() { + const list = document.getElementById("formulaHistory"); + const index = Number.parseInt(list.value, 10); + if (!Number.isInteger(index) || index < 0 || index >= formulaHistory.length) { + return null; + } + return formulaHistory[index]; +} + +function applySeedToEditor(seed) { + const sourceDocument = (seed && (seed.sourceDocument || seed.complexSource)) || ""; + if (sourceDocument) { + showComplexSource(sourceDocument); + return; + } + + if (seed && seed.sourceExpression) { + populateTemplate((prefs && prefs.template) || "", seed.sourceExpression); + return; + } + + populateTemplate((prefs && prefs.template) || "", (seed && seed.selection) || ""); +} + +async function refreshFormulaHistory() { + if (tabId === null) { + return; + } + formulaHistory = await getFormulaHistory(tabId); + renderFormulaHistory(); +} + +function loadSelectedHistoryFormula() { + const entry = getSelectedHistoryEntry(); + if (!entry) { + setStatus("Select a formula from history first."); + return; + } + + applySeedToEditor(entry); + setStatus("Loaded formula from history."); +} + function showComplexSource(source) { const textarea = document.getElementById("latexExpression"); textarea.value = source; @@ -135,15 +239,14 @@ async function load() { document.getElementById("autodpi").checked = Boolean(prefs.autodpi); document.getElementById("fontPx").value = Number(prefs.fontPx) || 16; - const seed = await getSelection(tabId); - const sourceDocument = seed.sourceDocument || seed.complexSource || ""; - if (sourceDocument) { - showComplexSource(sourceDocument); - } else if (seed.sourceExpression) { - populateTemplate(prefs.template, seed.sourceExpression); - } else { - populateTemplate(prefs.template, seed.selection); - } + const [seed, history] = await Promise.all([ + getSelection(tabId), + getFormulaHistory(tabId), + ]); + + formulaHistory = history; + renderFormulaHistory(); + applySeedToEditor(seed); } function updateAutodpiUi() { @@ -192,6 +295,20 @@ document.getElementById("resetTemplate").addEventListener("click", () => { } }); +document.getElementById("loadHistory").addEventListener("click", () => { + loadSelectedHistoryFormula(); +}); + +document.getElementById("refreshHistory").addEventListener("click", () => { + refreshFormulaHistory().catch((error) => { + setStatus(`History refresh failed: ${String(error)}`); + }); +}); + +document.getElementById("formulaHistory").addEventListener("dblclick", () => { + loadSelectedHistoryFormula(); +}); + document.getElementById("autodpi").addEventListener("change", () => { updateAutodpiUi(); }); From 8f07b1ab8686c1e9718344f771d0ce373a4e4ffc Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Sun, 22 Feb 2026 01:06:59 -0800 Subject: [PATCH 23/25] Add optional persistent formula history across compositions --- Changelog | 6 ++ api/TBLatex/implementation.js | 1 + background.js | 108 +++++++++++++++++++++++++++++++ compose/compose-script.js | 67 ++++++++++++++++++- defaults/preferences/defaults.js | 1 + manifest.json | 2 +- tblatex.xpi | Bin 28195 -> 29350 bytes ui/options.html | 8 +++ ui/options.js | 1 + 9 files changed, 191 insertions(+), 3 deletions(-) diff --git a/Changelog b/Changelog index d01c601..27ccb42 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,9 @@ +--v0.8.16 + +- Added a settings option to persist recent formula history across compose windows. +- Persistent history is now stored locally when enabled and reused by new compose sessions. +- Keeps session-only behavior by default when persistent history is disabled. + --v0.8.15 - Added a recent-formulas history list in the Insert Complex LaTeX dialog. diff --git a/api/TBLatex/implementation.js b/api/TBLatex/implementation.js index 9fd7f55..55f9647 100644 --- a/api/TBLatex/implementation.js +++ b/api/TBLatex/implementation.js @@ -302,6 +302,7 @@ function readLegacyPrefs() { ["log", "tblatex.log", "bool", false], ["debug", "tblatex.debug", "bool", false], ["warnOnUnconvertedLatex", "tblatex.warn_on_unconverted", "bool", true], + ["persistFormulaHistory", "tblatex.persist_formula_history", "bool", false], ["keepTempFiles", "tblatex.keeptempfiles", "bool", false], ["template", "tblatex.template", "string", ""], ]; diff --git a/background.js b/background.js index bae5f01..ec34fd3 100644 --- a/background.js +++ b/background.js @@ -11,6 +11,7 @@ const DEFAULT_PREFS = { log: false, debug: false, warnOnUnconvertedLatex: true, + persistFormulaHistory: false, keepTempFiles: false, template: "\\documentclass{article}\n" + @@ -32,6 +33,7 @@ const MENU_IDS = Object.freeze({ let composeScriptRegistration = null; const latexifyInFlightByTab = new Map(); +const FORMULA_HISTORY_LIMIT = 50; let helperHealthCache = { url: "", checkedAt: 0, @@ -78,6 +80,71 @@ function normalizeRenderScale(value) { return Math.min(8, Math.max(1, parsed)); } +function normalizeFormulaHistoryString(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + return trimmed ? trimmed : ""; +} + +function normalizeFormulaHistoryEntry(entry) { + if (!entry || typeof entry !== "object") { + return null; + } + + const sourceDocument = normalizeFormulaHistoryString(entry.sourceDocument); + const sourceExpression = normalizeFormulaHistoryString(entry.sourceExpression); + if (!sourceDocument && !sourceExpression) { + return null; + } + + let sourceMode = normalizeFormulaHistoryString(entry.sourceMode); + if (sourceMode !== "inline" && sourceMode !== "complex") { + sourceMode = sourceExpression ? "inline" : "complex"; + } + + const preview = normalizeFormulaHistoryString(entry.preview); + const savedAtRaw = Number(entry.savedAt); + const savedAt = Number.isFinite(savedAtRaw) && savedAtRaw > 0 ? savedAtRaw : Date.now(); + + return { + sourceMode, + sourceExpression, + sourceDocument, + preview, + savedAt, + }; +} + +function normalizeFormulaHistory(history) { + if (!Array.isArray(history)) { + return []; + } + + const normalized = []; + const seen = new Set(); + for (const candidate of history) { + const item = normalizeFormulaHistoryEntry(candidate); + if (!item) { + continue; + } + + const dedupeKey = item.sourceDocument || item.sourceExpression; + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + normalized.push(item); + + if (normalized.length >= FORMULA_HISTORY_LIMIT) { + break; + } + } + + return normalized; +} + async function getPrefs() { const { prefs = {} } = await browser.storage.local.get("prefs"); const merged = { ...DEFAULT_PREFS, ...prefs }; @@ -86,6 +153,7 @@ async function getPrefs() { merged.helperUrl = normalizeHelperUrl(merged.helperUrl); merged.renderScale = normalizeRenderScale(merged.renderScale); merged.helperFallbackEnabled = Boolean(merged.helperFallbackEnabled); + merged.persistFormulaHistory = Boolean(merged.persistFormulaHistory); return merged; } @@ -109,6 +177,9 @@ async function setPrefs(partialPrefs) { if (Object.prototype.hasOwnProperty.call(sanitized, "warnOnUnconvertedLatex")) { sanitized.warnOnUnconvertedLatex = Boolean(sanitized.warnOnUnconvertedLatex); } + if (Object.prototype.hasOwnProperty.call(sanitized, "persistFormulaHistory")) { + sanitized.persistFormulaHistory = Boolean(sanitized.persistFormulaHistory); + } const current = await getPrefs(); const next = { ...current, ...sanitized }; @@ -392,6 +463,39 @@ async function removeComposeRunReport(tabId) { } } +async function getFormulaHistoryStore() { + const prefs = await getPrefs(); + if (!prefs.persistFormulaHistory) { + return { enabled: false, history: [] }; + } + + const { formulaHistoryStore = [] } = await browser.storage.local.get("formulaHistoryStore"); + const history = normalizeFormulaHistory(formulaHistoryStore); + if (history.length !== formulaHistoryStore.length) { + await browser.storage.local.set({ formulaHistoryStore: history }); + } + + return { + enabled: true, + history, + }; +} + +async function setFormulaHistoryStore(history) { + const prefs = await getPrefs(); + if (!prefs.persistFormulaHistory) { + return { enabled: false, saved: false, count: 0 }; + } + + const normalized = normalizeFormulaHistory(history); + await browser.storage.local.set({ formulaHistoryStore: normalized }); + return { + enabled: true, + saved: true, + count: normalized.length, + }; +} + async function confirmComposeSendWithLatexCheck(tabId) { if (!tabId) { return true; @@ -548,6 +652,10 @@ async function handleRuntimeMessage(message, sender) { const prefs = await getPrefs(); return checkHelperHealth(prefs, true); } + case "getFormulaHistoryStore": + return getFormulaHistoryStore(); + case "setFormulaHistoryStore": + return setFormulaHistoryStore(message.history || []); case "openOptions": return browser.runtime.openOptionsPage(); case "runLatexifyFromDialog": diff --git a/compose/compose-script.js b/compose/compose-script.js index 046add0..c9ec0d6 100644 --- a/compose/compose-script.js +++ b/compose/compose-script.js @@ -8,6 +8,8 @@ const FORMULA_HISTORY_LIMIT = 50; let undoStack = []; let lastComplexExpression = ""; let formulaHistory = []; +let formulaHistoryHydrated = false; +let formulaHistorySyncTimer = null; function insertAfter(nodeToInsert, referenceNode) { const parentNode = referenceNode.parentNode; @@ -352,7 +354,7 @@ function normalizeFormulaSeed(seed) { }; } -function addFormulaToHistory(seed) { +function addFormulaToHistory(seed, options = {}) { const normalizedSeed = normalizeFormulaSeed(seed); if (!normalizedSeed) { return; @@ -369,6 +371,20 @@ function addFormulaToHistory(seed) { if (formulaHistory.length > FORMULA_HISTORY_LIMIT) { formulaHistory.length = FORMULA_HISTORY_LIMIT; } + + if (options.sync !== false) { + scheduleFormulaHistorySync(); + } +} + +function mergeFormulaHistorySeeds(seeds) { + if (!Array.isArray(seeds) || !seeds.length) { + return; + } + + for (let i = seeds.length - 1; i >= 0; i--) { + addFormulaToHistory(seeds[i], { sync: false }); + } } function getFormulaHistory() { @@ -382,6 +398,53 @@ function getFormulaHistory() { })); } +async function ensureFormulaHistoryHydrated() { + if (formulaHistoryHydrated) { + return; + } + formulaHistoryHydrated = true; + + const localSnapshot = formulaHistory.slice(); + formulaHistory = []; + + try { + const result = await browser.runtime.sendMessage({ + command: "getFormulaHistoryStore", + }); + const storedHistory = result && Array.isArray(result.history) ? result.history : []; + mergeFormulaHistorySeeds(storedHistory); + } catch (error) { + // Keep local-only history if storage lookup fails. + } + + mergeFormulaHistorySeeds(localSnapshot); +} + +function scheduleFormulaHistorySync() { + if (formulaHistorySyncTimer !== null) { + return; + } + + formulaHistorySyncTimer = setTimeout(async () => { + formulaHistorySyncTimer = null; + + try { + await ensureFormulaHistoryHydrated(); + await browser.runtime.sendMessage({ + command: "setFormulaHistoryStore", + history: getFormulaHistory(), + }); + } catch (error) { + // Ignore history sync failures and keep local history available. + } + }, 250); +} + +async function getFormulaHistoryForUi() { + await ensureFormulaHistoryHydrated(); + return getFormulaHistory(); +} + function makeImageFromResult(result, altText, titleText, options = {}) { const renderScale = Number(result && result.renderScale) > 0 ? Number(result.renderScale) @@ -889,7 +952,7 @@ browser.runtime.onMessage.addListener((message) => { case "getInsertComplexSeed": return Promise.resolve(getInsertComplexSeed()); case "getFormulaHistory": - return Promise.resolve(getFormulaHistory()); + return getFormulaHistoryForUi(); case "hasLogReport": return Promise.resolve(Boolean(document.getElementById(LOG_PANEL_ID))); case "removeLogReport": { diff --git a/defaults/preferences/defaults.js b/defaults/preferences/defaults.js index 57c58b1..f3f4972 100644 --- a/defaults/preferences/defaults.js +++ b/defaults/preferences/defaults.js @@ -6,6 +6,7 @@ pref("tblatex.render_scale", 4); pref("tblatex.log", false); pref("tblatex.debug", false); pref("tblatex.warn_on_unconverted", true); +pref("tblatex.persist_formula_history", false); pref("tblatex.keeptempfiles", false); pref("tblatex.template", "\\documentclass{article}\n\\usepackage[utf8]{inputenc}\n\\usepackage[active,displaymath,textmath]{preview} % DO NOT DELETE - this is required for baseline alignment\n\\pagestyle{empty}\n\\begin{document}\n__REPLACE_ME__ % this is where your LaTeX expression goes between $$\n\\end{document}\n"); pref("tblatex.firstrun", 0); diff --git a/manifest.json b/manifest.json index e39278b..a12e41e 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "LaTeX It!", "description": "Automatically change $\\LaTeX$ into images in your HTML mails.", - "version": "0.8.15", + "version": "0.8.16", "author": "Jonathan Protzenko", "homepage_url": "https://github.com/protz/LatexIt/wiki", "applications": { diff --git a/tblatex.xpi b/tblatex.xpi index ce536eb1284630ff0c41b40cb708546d420ce11b..2a5387b8cc4f502be83cfd71e4a747518ad54402 100644 GIT binary patch delta 18988 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
+
+

History

+ +
+

Template

Use __REPLACE_ME__ where your LaTeX expression should be inserted.

diff --git a/ui/options.js b/ui/options.js index 78c4281..d260a08 100644 --- a/ui/options.js +++ b/ui/options.js @@ -11,6 +11,7 @@ const FIELDS = [ "log", "debug", "warnOnUnconvertedLatex", + "persistFormulaHistory", "keepTempFiles", "template", ]; From fa663f14b0a52fcb0b6e9ba459030b8c7f4a162d Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Wed, 25 Feb 2026 00:54:56 -0800 Subject: [PATCH 24/25] Improve insert dialog usability and persist popup size --- background.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++-- ui/insert.css | 54 ++++++++++++++++++++++++++++++++++++----- ui/insert.html | 60 +++++++++++++++++++++++++--------------------- ui/insert.js | 45 ++++++++++++++++++++++++++++++++++ ui/options.html | 34 +++++++++++++------------- 5 files changed, 205 insertions(+), 52 deletions(-) diff --git a/background.js b/background.js index ec34fd3..70c713f 100644 --- a/background.js +++ b/background.js @@ -30,6 +30,18 @@ const MENU_IDS = Object.freeze({ INSERT: "tblatex-insert-complex", OPTIONS: "tblatex-open-options", }); +const INSERT_DIALOG_DEFAULT_SIZE = Object.freeze({ + width: 900, + height: 700, +}); +const INSERT_DIALOG_MIN_SIZE = Object.freeze({ + width: 720, + height: 560, +}); +const INSERT_DIALOG_MAX_SIZE = Object.freeze({ + width: 2200, + height: 1800, +}); let composeScriptRegistration = null; const latexifyInFlightByTab = new Map(); @@ -80,6 +92,14 @@ function normalizeRenderScale(value) { return Math.min(8, Math.max(1, parsed)); } +function normalizeDialogDimension(value, minValue, maxValue, defaultValue) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return defaultValue; + } + return Math.min(maxValue, Math.max(minValue, parsed)); +} + function normalizeFormulaHistoryString(value) { if (typeof value !== "string") { return ""; @@ -557,13 +577,51 @@ async function runUndoAll(tabId) { } async function openInsertDialog(tabId) { + const { insertDialogSize = {} } = await browser.storage.local.get("insertDialogSize"); + const width = normalizeDialogDimension( + insertDialogSize.width, + INSERT_DIALOG_MIN_SIZE.width, + INSERT_DIALOG_MAX_SIZE.width, + INSERT_DIALOG_DEFAULT_SIZE.width + ); + const height = normalizeDialogDimension( + insertDialogSize.height, + INSERT_DIALOG_MIN_SIZE.height, + INSERT_DIALOG_MAX_SIZE.height, + INSERT_DIALOG_DEFAULT_SIZE.height + ); + const url = browser.runtime.getURL(`ui/insert.html?tabId=${encodeURIComponent(tabId)}`); await browser.windows.create({ url, type: "popup", - width: 900, - height: 700, + width, + height, + }); +} + +async function saveInsertDialogSize(payload) { + if (!payload || typeof payload !== "object") { + return { ok: false }; + } + + const width = normalizeDialogDimension( + payload.width, + INSERT_DIALOG_MIN_SIZE.width, + INSERT_DIALOG_MAX_SIZE.width, + INSERT_DIALOG_DEFAULT_SIZE.width + ); + const height = normalizeDialogDimension( + payload.height, + INSERT_DIALOG_MIN_SIZE.height, + INSERT_DIALOG_MAX_SIZE.height, + INSERT_DIALOG_DEFAULT_SIZE.height + ); + + await browser.storage.local.set({ + insertDialogSize: { width, height }, }); + return { ok: true, width, height }; } async function withActiveComposeTab(handler) { @@ -656,6 +714,8 @@ async function handleRuntimeMessage(message, sender) { return getFormulaHistoryStore(); case "setFormulaHistoryStore": return setFormulaHistoryStore(message.history || []); + case "saveInsertDialogSize": + return saveInsertDialogSize(message); case "openOptions": return browser.runtime.openOptionsPage(); case "runLatexifyFromDialog": diff --git a/ui/insert.css b/ui/insert.css index cc5b920..f636e96 100644 --- a/ui/insert.css +++ b/ui/insert.css @@ -2,6 +2,11 @@ box-sizing: border-box; } +html, +body { + height: 100%; +} + body { margin: 0; font: 14px/1.4 sans-serif; @@ -10,13 +15,29 @@ body { } main { - max-width: 960px; + max-width: 1000px; margin: 0 auto; - padding: 20px; + padding: 12px 16px; + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; } h1 { margin-top: 0; + margin-bottom: 2px; +} + +.dialog-content { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding-right: 2px; } textarea, @@ -31,20 +52,22 @@ select { textarea { resize: vertical; + min-height: 220px; + flex: 1 1 auto; } .history { - margin: 12px 0; + margin: 0; } #formulaHistory { - min-height: 8.5em; + min-height: 6.5em; } .row { display: grid; gap: 12px; - margin-top: 12px; + margin-top: 0; } .checkbox { @@ -56,7 +79,20 @@ textarea { .actions { display: flex; gap: 10px; - margin-top: 12px; + margin-top: 0; + flex-wrap: wrap; +} + +.compact-actions { + justify-content: flex-start; +} + +.footer-actions { + justify-content: flex-end; + align-items: center; + border-top: 1px solid #ddd; + padding-top: 8px; + flex-wrap: nowrap; } button { @@ -79,6 +115,12 @@ button { background: #666; } +#cancel { + border-color: #666; + background: #666; +} + #status { min-height: 1.4em; + margin: 0; } diff --git a/ui/insert.html b/ui/insert.html index 38b0372..d78144f 100644 --- a/ui/insert.html +++ b/ui/insert.html @@ -9,39 +9,45 @@

Insert Complex LaTeX

-

- Edit the LaTeX document below. The visible result will be inserted at the - current cursor position in the compose editor. -

-

- If a LaTeX image is selected in the compose body, this dialog edits and - replaces that formula in place. -

+
+

+ Edit the LaTeX document below. The visible result will be inserted at the + current cursor position in the compose editor. +

+

+ If a LaTeX image is selected in the compose body, this dialog edits and + replaces that formula in place. +

-
- - -
- - +
+ + +
+ + +
-
- + + +
+ +
-
- - +
+ + +
-
- + diff --git a/ui/insert.js b/ui/insert.js index 04fac63..23a2ca9 100644 --- a/ui/insert.js +++ b/ui/insert.js @@ -6,6 +6,7 @@ const oldMarker = "__REPLACEME__"; let prefs = null; let tabId = null; let formulaHistory = []; +let saveDialogSizeTimer = null; function setStatus(message) { document.getElementById("status").textContent = message; @@ -144,6 +145,7 @@ function formatHistoryItem(item) { function renderFormulaHistory() { const list = document.getElementById("formulaHistory"); const loadButton = document.getElementById("loadHistory"); + const historySection = document.getElementById("historySection"); while (list.firstChild) { list.removeChild(list.firstChild); @@ -159,6 +161,7 @@ function renderFormulaHistory() { const hasItems = formulaHistory.length > 0; list.disabled = !hasItems; loadButton.disabled = !hasItems; + historySection.hidden = !hasItems; if (hasItems) { list.selectedIndex = 0; } @@ -283,12 +286,42 @@ async function insertExpression() { } } +async function saveCurrentDialogSize() { + try { + const currentWindow = await browser.windows.getCurrent(); + if (!currentWindow) { + return; + } + await browser.runtime.sendMessage({ + command: "saveInsertDialogSize", + width: currentWindow.width, + height: currentWindow.height, + }); + } catch (error) { + // Non-fatal in case the window API is unavailable. + } +} + +function scheduleDialogSizeSave() { + if (saveDialogSizeTimer !== null) { + clearTimeout(saveDialogSizeTimer); + } + saveDialogSizeTimer = setTimeout(() => { + saveDialogSizeTimer = null; + saveCurrentDialogSize().catch(() => {}); + }, 350); +} + document.getElementById("insert").addEventListener("click", () => { insertExpression().catch((error) => { setStatus(`Insert failed: ${String(error)}`); }); }); +document.getElementById("cancel").addEventListener("click", () => { + window.close(); +}); + document.getElementById("resetTemplate").addEventListener("click", () => { if (prefs) { populateTemplate(prefs.template, ""); @@ -313,6 +346,18 @@ document.getElementById("autodpi").addEventListener("change", () => { updateAutodpiUi(); }); +window.addEventListener("resize", () => { + scheduleDialogSizeSave(); +}); + +window.addEventListener("beforeunload", () => { + if (saveDialogSizeTimer !== null) { + clearTimeout(saveDialogSizeTimer); + saveDialogSizeTimer = null; + } + saveCurrentDialogSize().catch(() => {}); +}); + load() .then(() => { updateAutodpiUi(); diff --git a/ui/options.html b/ui/options.html index c7b04e4..fb0ca58 100644 --- a/ui/options.html +++ b/ui/options.html @@ -43,7 +43,7 @@

Sandbox Helper Fallback

-

Appearance

+

Formula Rendering

-
-

Debugging

- - - -
-

Sending

+
+

Debugging

+ + + +
+
From 77526ba1a9e82b69c3df19789a114ce802be9fcf Mon Sep 17 00:00:00 2001 From: Andrew Boldi Date: Wed, 25 Feb 2026 00:57:15 -0800 Subject: [PATCH 25/25] Remove obsolete legacy XUL extension files --- chrome.manifest | 5 - content/accept.png | Bin 781 -> 0 bytes content/exclamation.png | Bin 701 -> 0 bytes content/firstrun.css | 17 - content/firstrun.html | 147 ------ content/firstrun.js | 20 - content/help.html | 62 --- content/icon.png | Bin 672 -> 0 bytes content/insert.js | 67 --- content/insert.xul | 32 -- content/main.js | 779 ------------------------------- content/options.js | 49 -- content/options.xul | 83 ---- content/osx-path-hacks.js | 22 - content/overlay.xul | 61 --- content/overlay_firstrun.xul | 5 - content/preferences.js | 10 - content/sendalert.js | 20 - content/sendalert.xul | 20 - defaults/preferences/defaults.js | 12 - skin/button.png | Bin 680 -> 0 bytes skin/buttonsmall.png | Bin 650 -> 0 bytes skin/overlay.css | 7 - 23 files changed, 1418 deletions(-) delete mode 100644 chrome.manifest delete mode 100644 content/accept.png delete mode 100644 content/exclamation.png delete mode 100644 content/firstrun.css delete mode 100644 content/firstrun.html delete mode 100644 content/firstrun.js delete mode 100644 content/help.html delete mode 100644 content/icon.png delete mode 100644 content/insert.js delete mode 100644 content/insert.xul delete mode 100644 content/main.js delete mode 100644 content/options.js delete mode 100644 content/options.xul delete mode 100644 content/osx-path-hacks.js delete mode 100644 content/overlay.xul delete mode 100644 content/overlay_firstrun.xul delete mode 100644 content/preferences.js delete mode 100644 content/sendalert.js delete mode 100644 content/sendalert.xul delete mode 100644 defaults/preferences/defaults.js delete mode 100644 skin/button.png delete mode 100644 skin/buttonsmall.png delete mode 100644 skin/overlay.css diff --git a/chrome.manifest b/chrome.manifest deleted file mode 100644 index f1c9e0f..0000000 --- a/chrome.manifest +++ /dev/null @@ -1,5 +0,0 @@ -content tblatex content/ -skin tblatex classic/1.0 skin/ -overlay chrome://messenger/content/messenger.xul chrome://tblatex/content/overlay_firstrun.xul -overlay chrome://messenger/content/messengercompose/messengercompose.xul chrome://tblatex/content/overlay.xul -overlay chrome://global/content/customizeToolbar.xul chrome://tblatex/content/overlay.xul diff --git a/content/accept.png b/content/accept.png deleted file mode 100644 index 89c8129a490b329f3165f32fa0781701aab417ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 781 zcmV+o1M>WdP)4-QibtN)VXQDpczE`xXAkUjh%RI>;okxb7K@0kpyQ1k_Y(|Oe7$m(^ zNYX>mI||sUbmn+c3<&FnE=4u#()KBS^SH8e)Qs5i!#lY=$-1gbH6VluzU=m=EP78&5vQ z-?+fFP-G2l&l_QzYealK$;1Rl?FkzXR&Jv@fBPNjCr#AYRyJ7UJQ0v#?)7Ott=>3`#-pV!7>9}>Q1jL)H6h&gkP@3nI=+F3nA~M>u#(n* z8T!#8oEw&-mED4!h4s!N@Jo3S7N&Q6%6l3}nlcd~X@>;uelvPsSkXIgg~e+^T1zSf z3SNj(5%jK~i8@b;CN#0$9Ug7g~-`rQ^qx~m@y2OU8A z#zh~=7n#Z$Z*fx-GOtDf07cgx0suCz_W(2~Y(0tf@FX@P6EPuM_dgn$vj9LucO)%W zw%HgMW>=#oL>nZ>M&NEf08>)#)k<{$fCT_r>rPi=BV=hFh6WS^qqze>C6Ek}o{M5% za|@JGowu0t{&hgNzySHZxy@LTNh);YzZ2zSp_ zl$^T&Dnc|NLb&RD_!4>pt@VHdP)ZGER%5ZmWEe$lryR&y;2u^3cOkO4#6c%-(EY6a{600000NkvXXu0mjfxS2AI diff --git a/content/firstrun.css b/content/firstrun.css deleted file mode 100644 index e65b958..0000000 --- a/content/firstrun.css +++ /dev/null @@ -1,17 +0,0 @@ -body { - font-family: "DejaVu Sans Condensed", "Arial Narrow", condensed; - margin: 3em; -} - -h1 { - border-bottom: 1px solid black; -} - -a { - color: #6DC361; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} diff --git a/content/firstrun.html b/content/firstrun.html deleted file mode 100644 index 058e258..0000000 --- a/content/firstrun.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - Configuring Thunderbird LaTeX extension - - - - - -

Thunderbird LaTeX extension

- Congratulations! You have successfully installed the Thunderbird LaTeX - extension. Please take a few seconds to check that the settings we have - found are correct. -

Required software

-

- This extension depends on a regular LaTeX distribution and the dvipng - software. If you haven't installed them already, here are a few tips. If - you are running Linux, both can be easily installed through your - distribution's package manager. If running MacOS, MacTeX is probably what you're - looking for. If running Windows, MiKTeX is known to work. -

-

- If you choose to install this software now, please make sure you click - "autodetect again" at the bottom of the page once you're done with the - setup. -

- -

Autodetection results

-

- We have found the required utilities to be in the following locations: -

-
    -
  • LaTeX:  
  • -
  • dvipng:  
  • -
- -

Are these settings correct?

-

- - -

- Autodetect again - - - diff --git a/content/firstrun.js b/content/firstrun.js deleted file mode 100644 index cb0fbb8..0000000 --- a/content/firstrun.js +++ /dev/null @@ -1,20 +0,0 @@ -(function () { - function on_load() { - var prefs = Components.classes["@mozilla.org/preferences-service;1"] - .getService(Components.interfaces.nsIPrefService) - .getBranch("tblatex."); - if (prefs.getIntPref("firstrun") == 3) - return; - - var tabmail = document.getElementById("tabmail"); - if (tabmail && 'openTab' in tabmail) /* Took this from Personas code ("Browse gallery"...) */ - Components.classes['@mozilla.org/appshell/window-mediator;1']. - getService(Components.interfaces.nsIWindowMediator). - getMostRecentWindow("mail:3pane"). - document.getElementById("tabmail"). - openTab("contentTab", { contentPage: "chrome://tblatex/content/firstrun.html" }); - else - openDialog("chrome://tblatex/content/firstrun.html", "", "width=640,height=480"); - }; - window.addEventListener("load", on_load, false); -})(); diff --git a/content/help.html b/content/help.html deleted file mode 100644 index dcbc55a..0000000 --- a/content/help.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - Latex It! help - - - -

- More about templates -

-

- The default template gives LaTeX expressions the usual, familiar look of - CM fonts. It is as minimal as possible to ensure that it works on every - platform. -

-
-\documentclass{article}
-\usepackage[utf8]{inputenc}
-\usepackage[active,displaymath,textmath]{preview} % DO NOT DELETE - this is required for baseline alignment
-\pagestyle{empty}
-\begin{document}
-__REPLACE_ME__ % this is where your LaTeX expression goes between $$
-\end{document}
-    
-

- To insert LaTeX, we convert dvi to png directly. In the - process the PNG file is cropped to a small image - that only contains the formula. The \pagestyle{empty} line removes - extra output such as page numbers, which is why cropping works. -

-

- The __REPLACE_ME__ word will be replaced with the LaTeX expression - you specified. Therefore, it is important you put it in the right place. -

-

- The file is written as UTF-8 (always) so please do not remove the - utf8 encoding. -

-

Alternative templates

-

- This one uses the "Palatino" font, and includes several AMS - packages to make it easier to insert symbols and specific features. The - mathrsfs package allows one to use \mathscr{} to - produce English capitals. -

-
-\documentclass{article}
-\usepackage[utf8]{inputenc}
-\usepackage[active,displaymath,textmath]{preview} % DO NOT DELETE - this is required for baseline alignment
-\usepackage{amsmath,amssymb,amsopn}
-\usepackage{mathrsfs}
-\usepackage{palatino}
-\usepackage{mathpazo}
-\pagestyle{empty}
-\begin{document}
-__REPLACE_ME__ % this is where your LaTeX expression goes between $$
-\end{document}
-    
- - - diff --git a/content/icon.png b/content/icon.png deleted file mode 100644 index 937ab3f546ac6db34d329b02df3b5216c96ef2c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 672 zcmV;R0$=@!P)(>Wy4Q7dA+f-izQi(n|~q=pxC5Tx~}oOhQM>~5hyj46y2H7pu!1XC6>_>Fy? zY}_syF05wcb5TChPva%cyBkjAQ;EE<#!n{SACvb>$@?I_ZnMuhAw~wV6EASLT>^*N zSoa)Pgw$KZ5gd%Vd$-V8!x=nmwfS55uAZ=cj<-8nrsJz*@pxvgicfKF1xrGXPvf%i zgDPc6FJuTT6d{n!f=-bou_XpmpL+S+@9%$>2m<^h1T^es3YDIoeu?eF#=WjRlzZUnHEu!=wL*S2)>qRjr zoP4pUVp+53q~k$>Jw~F=qGk{l!tfR(8off*#y=*Zde($~FdIMPI3C~2@%x0$6?X$F zn%z;6k?o9+3Ld$Q^SF)!aqbZ=Zbw2q>78>JlmM*KV$QnHfF&Ei%o z0^MRvaQm`wkQ7gFwci9_zfj)yB(e&Ai9KdVky-#F_-11{p2Tasmw$CV#Z4gyQ!EKB z9Bl+*Hz?s9E@b3Cu}etLu^oLRP!{^dI{qf|H#mmRc=jLB0n_!XOAnX;0000 -1) { - marker = oldmarker; - } - } - if (start > -1) - if (selection) { - // Replace marker with selection - template = template.substring(0, start) + selection + template.substring(start+marker.length) - marker = selection; - } - else { - // Insert $ on either side of the marker, so it's easier for the user - template = template.slice(0, start) + "$" + template.slice(start, start+marker.length) + "$" + template.slice(start+marker.length); - start += 1 - } - - var textarea = document.getElementById("tblatex-expr"); - textarea.value = template; - textarea.focus(); - if (start > -1) - // Select marker or selection - textarea.setSelectionRange(start, start+marker.length); -} - -function update_ui(autodpi) { - document.getElementById("fontpx-label").disabled = autodpi; - document.getElementById("fontpx").disabled = autodpi; - document.getElementById("fontpx-unit").disabled = autodpi; -} diff --git a/content/insert.xul b/content/insert.xul deleted file mode 100644 index 70ab061..0000000 --- a/content/insert.xul +++ /dev/null @@ -1,32 +0,0 @@ - - - - -