diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..503e952 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.claude +CLAUDE.md diff --git a/Changelog b/Changelog index 7ae72d4..27ccb42 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,45 @@ +--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. +- 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. +- 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. +- 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). +- Added helper runtime detection and helper health checks in options UI. +- Added `helper/tblatex_helper.py` service for out-of-sandbox LaTeX rendering. +- Added Linux `systemd --user` install/uninstall scripts for helper auto-start. + +--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..a3b1f0c 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ -EXCLUDES = $(addprefix --exclude , $(shell find . -iname '.*.sw*')) +ADDON_ID ?= +OUT ?= tblatex.xpi all: dist -.PHONY: dist +.PHONY: dist clean dist: + ./scripts/build_xpi.sh "$(OUT)" "$(ADDON_ID)" + +clean: rm -f tblatex.xpi - zip tblatex.xpi $(EXCLUDES) --exclude Makefile --exclude TODO --exclude icon.xcf --exclude tblatex.xpi -r * diff --git a/README.md b/README.md index 267131d..2f8c0ad 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,109 @@ 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 +- Auto-fallback to local helper service for sandboxed Thunderbird installs + +Requirements +------------ + +- Thunderbird 140 or newer +- A local TeX setup with: + - `latex` + - `dvipng` +- For sandboxed Thunderbird (Snap/Flatpak), use the local helper service. + Preferred on Linux: install it as a `systemd --user` service. + +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). +6. If Thunderbird is sandboxed, enable helper fallback in options and make sure + helper URL matches your helper service. + +Publishing a fork on ATN +------------------------ + +If you are publishing your own fork (not updating the original add-on listing), +build with a unique add-on ID: + +```sh +make ADDON_ID='tblatex@your-domain-or-handle.example' OUT='tblatex-fork.xpi' +``` + +Upload `tblatex-fork.xpi` to ATN. This avoids "Duplicate add-on ID found." + +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). + +Sandbox Fallback (Snap/Flatpak) +------------------------------- + +Primary option (Linux): install helper as a user service + +```sh +bash helper/install-systemd-user.sh +``` + +Fallback option: run helper manually + +```sh +python3 helper/tblatex_helper.py +``` + +Then 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 new file mode 100644 index 0000000..55f9647 --- /dev/null +++ b/api/TBLatex/implementation.js @@ -0,0 +1,542 @@ +"use strict"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const DEFAULT_RENDER_SCALE = 4; + +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(normalized); + return file; + } catch (error) { + return null; + } +} + +function fileExists(path) { + const file = initLocalFile(path); + return Boolean(file && file.exists()); +} + +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); + return new Promise((resolve, reject) => { + let settled = false; + const settle = (callback, value) => { + if (settled) { + return; + } + settled = true; + callback(value); + }; + + const observer = { + observe(_subject, topic) { + if (topic === "process-finished") { + settle(resolve, process.exitValue); + return; + } + if (topic === "process-failed") { + settle(reject, new Error(`Process failed: ${binaryFile.path}`)); + } + }, + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + }; + + try { + process.runAsync(args, args.length, observer, false); + } catch (error) { + settle(reject, error); + } + }); +} + +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 readFileBytes(file, maxBytes = -1) { + 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); + + let size = 0; + try { + size = Math.max(0, Number(file.fileSize) || 0); + } catch (error) { + size = 0; + } + if (size <= 0) { + size = Math.max(0, binaryStream.available()); + } + + let count = size; + if (Number.isFinite(maxBytes) && maxBytes >= 0) { + count = Math.min(Math.max(0, Number(maxBytes) || 0), size); + } + const bytes = count > 0 ? binaryStream.readByteArray(count) : []; + try { + binaryStream.close(); + } catch (error) { + // ignore + } + try { + inputStream.close(); + } catch (error) { + // ignore + } + + return bytes; +} + +function hasPngSignature(bytes) { + if (!bytes || bytes.length < 8) { + return false; + } + const signature = [137, 80, 78, 71, 13, 10, 26, 10]; + for (let i = 0; i < signature.length; i++) { + if (bytes[i] !== signature[i]) { + return false; + } + } + return true; +} + +function bytesToBase64(bytes) { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let output = ""; + + for (let i = 0; i < bytes.length; i += 3) { + const byte0 = (bytes[i] || 0) & 0xff; + const byte1 = (bytes[i + 1] || 0) & 0xff; + const byte2 = (bytes[i + 2] || 0) & 0xff; + + const triplet = (byte0 << 16) | (byte1 << 8) | byte2; + + output += alphabet[(triplet >> 18) & 0x3f]; + output += alphabet[(triplet >> 12) & 0x3f]; + output += i + 1 < bytes.length ? alphabet[(triplet >> 6) & 0x3f] : "="; + output += i + 2 < bytes.length ? alphabet[triplet & 0x3f] : "="; + } + + return output; +} + +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 normalizeRenderScale(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return DEFAULT_RENDER_SCALE; + } + return Math.min(8, Math.max(1, parsed)); +} + +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/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); + } + } + } + + 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], + ["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], + ["persistFormulaHistory", "tblatex.persist_formula_history", "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); +} + +function getRuntimeInfo() { + let sandboxed = false; + let sandboxType = "none"; + + try { + const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + 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) { + // ignore + } + + return { + sandboxed, + sandboxType, + }; +} + +var TBLatex = class extends ExtensionCommon.ExtensionAPI { + getAPI(context) { + return { + TBLatex: { + async render( + latexExpression, + fontPx, + fontColor, + latexPath, + dvipngPath, + autodpi, + defaultFontPx, + renderScale, + debug, + keepTempFiles + ) { + let status = 0; + let log = ""; + let files = null; + latexPath = normalizeExecutablePath(latexPath); + dvipngPath = normalizeExecutablePath(dvipngPath); + + 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)) { + const runtimeInfo = getRuntimeInfo(); + const snapHint = runtimeInfo.sandboxType === "snap" + ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries directly)" + : ""; + return { + status: 2, + depth: 0, + dataUrl: "", + log: + `!!! Wrong path for 'latex' executable: "${latexPath || "(empty)"}". Set it in LaTeX It! options${snapHint}.\n`, + }; + } + + if (!fileExists(dvipngPath)) { + const runtimeInfo = getRuntimeInfo(); + const snapHint = runtimeInfo.sandboxType === "snap" + ? " (Thunderbird Snap build cannot access host /usr/bin TeX binaries directly)" + : ""; + return { + status: 2, + depth: 0, + dataUrl: "", + log: + `!!! Wrong path for 'dvipng' executable: "${dvipngPath || "(empty)"}". Set it in LaTeX It! options${snapHint}.\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 = await 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 normalizedRenderScale = normalizeRenderScale(renderScale); + const baseDpi = (sizePx * 72.27) / 10; + const dpi = baseDpi * normalizedRenderScale; + const safeColor = fontColor && fontColor.trim() ? fontColor : "RGB 0 0 0"; + + if (debug) { + log += `*** Using dpi=${dpi} (base=${baseDpi}, scale=${normalizedRenderScale}x) 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 = await 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 signatureBytes = readFileBytes(files.pngFile, 8); + if (!hasPngSignature(signatureBytes)) { + return { + status: 2, + depth: 0, + dataUrl: "", + log: + log + + "!!! Direct renderer produced invalid PNG bytes. Rendering aborted.\n", + }; + } + const pngBytes = readFileBytes(files.pngFile); + const base64Png = bytesToBase64(pngBytes); + const dataUrl = `data:image/png;base64,${base64Png}`; + + return { + status, + depth: 0, + dataUrl, + renderScale: normalizedRenderScale, + 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(); + }, + + async getRuntimeInfo() { + return getRuntimeInfo(); + }, + }, + }; + } +}; diff --git a/api/TBLatex/schema.json b/api/TBLatex/schema.json new file mode 100644 index 0000000..55aad1f --- /dev/null +++ b/api/TBLatex/schema.json @@ -0,0 +1,72 @@ +[ + { + "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": "renderScale", + "type": "integer" + }, + { + "name": "debug", + "type": "boolean" + }, + { + "name": "keepTempFiles", + "type": "boolean" + } + ] + }, + { + "name": "detectExecutables", + "type": "function", + "async": true, + "parameters": [] + }, + { + "name": "readLegacyPrefs", + "type": "function", + "async": true, + "parameters": [] + }, + { + "name": "getRuntimeInfo", + "type": "function", + "async": true, + "parameters": [] + } + ] + } +] diff --git a/background.js b/background.js new file mode 100644 index 0000000..70c713f --- /dev/null +++ b/background.js @@ -0,0 +1,797 @@ +"use strict"; + +const DEFAULT_PREFS = { + latexPath: "", + dvipngPath: "", + helperFallbackEnabled: true, + helperUrl: "http://127.0.0.1:3737", + autodpi: true, + fontPx: 16, + renderScale: 4, + log: false, + debug: false, + warnOnUnconvertedLatex: true, + persistFormulaHistory: 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", +}); +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(); +const FORMULA_HISTORY_LIMIT = 50; +let helperHealthCache = { + url: "", + checkedAt: 0, + ok: false, + error: "", +}; + +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; +} + +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(/\/+$/, ""); +} + +function normalizeRenderScale(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return DEFAULT_PREFS.renderScale; + } + 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 ""; + } + 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 }; + merged.latexPath = normalizeExecutablePath(merged.latexPath); + merged.dvipngPath = normalizeExecutablePath(merged.dvipngPath); + merged.helperUrl = normalizeHelperUrl(merged.helperUrl); + merged.renderScale = normalizeRenderScale(merged.renderScale); + merged.helperFallbackEnabled = Boolean(merged.helperFallbackEnabled); + merged.persistFormulaHistory = Boolean(merged.persistFormulaHistory); + 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); + } + if (Object.prototype.hasOwnProperty.call(sanitized, "helperUrl")) { + sanitized.helperUrl = normalizeHelperUrl(sanitized.helperUrl); + } + if (Object.prototype.hasOwnProperty.call(sanitized, "renderScale")) { + sanitized.renderScale = normalizeRenderScale(sanitized.renderScale); + } + if (Object.prototype.hasOwnProperty.call(sanitized, "helperFallbackEnabled")) { + sanitized.helperFallbackEnabled = Boolean(sanitized.helperFallbackEnabled); + } + 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 }; + 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); + } +} + +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, + renderScale: prefs.renderScale, + 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.renderScale, + 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; + } + + 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 removeComposeRunReport(tabId) { + if (!tabId) { + return { ok: false, removed: false }; + } + + try { + const result = await sendComposeCommand(tabId, { command: "removeLogReport" }); + return { + ok: true, + removed: Boolean(result && result.removed), + }; + } catch (error) { + // Don't block sending if the compose script is unavailable for this tab. + console.warn("Could not remove run report before send:", error); + return { ok: false, removed: false }; + } +} + +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; + } + + 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" }; + } + + if (!(await isHtmlComposeTab(tabId))) { + await notify( + "LaTeX It!", + "Cannot run LaTeX conversion in plain text compose mode." + ); + return { ok: false }; + } + + latexifyInFlightByTab.set(tabId, true); + 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) }; + } finally { + latexifyInFlightByTab.delete(tabId); + } +} + +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 { 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, + 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) { + 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": + return renderLatexMessage(message || {}); + case "getRuntimeInfo": + return browser.TBLatex.getRuntimeInfo(); + case "testHelper": { + const prefs = await getPrefs(); + return checkHelperHealth(prefs, true); + } + case "getFormulaHistoryStore": + return getFormulaHistoryStore(); + case "setFormulaHistoryStore": + return setFormulaHistoryStore(message.history || []); + case "saveInsertDialogSize": + return saveInsertDialogSize(message); + 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); + }); +}); + +if (browser.compose && browser.compose.onBeforeSend) { + browser.compose.onBeforeSend.addListener(async (tab, _details) => { + if (!tab || !tab.id) { + return {}; + } + + await removeComposeRunReport(tab.id); + const prefs = await getPrefs(); + if (!prefs.warnOnUnconvertedLatex) { + return {}; + } + + const okToSend = await confirmComposeSendWithLatexCheck(tab.id); + if (!okToSend) { + return { cancel: true }; + } + + return {}; + }); +} + +browser.runtime.onMessage.addListener((message, sender) => handleRuntimeMessage(message, sender)); + +initialize().catch((error) => { + console.error("Initialization failed:", error); +}); 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/compose/compose-script.js b/compose/compose-script.js new file mode 100644 index 0000000..c9ec0d6 --- /dev/null +++ b/compose/compose-script.js @@ -0,0 +1,968 @@ +"use strict"; + +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 = []; +let formulaHistoryHydrated = false; +let formulaHistorySyncTimer = null; + +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 findTemplateMarker(template) { + 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) { + 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) { + 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 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 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, options = {}) { + 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; + } + + 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() { + return formulaHistory.map((item, index) => ({ + id: String(index), + sourceMode: item.sourceMode, + sourceExpression: item.sourceExpression, + sourceDocument: item.sourceDocument, + preview: item.preview, + savedAt: item.savedAt, + })); +} + +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) + : 1; + const depth = Number(result && result.depth) || 0; + const img = document.createElement("img"); + img.alt = altText; + img.title = titleText; + img.style.verticalAlign = `-${depth / renderScale}px`; + img.src = result.dataUrl; + applyFormulaMetadata(img, options); + + if (renderScale > 1) { + const applyDisplayScale = () => { + if (!img.naturalWidth || !img.naturalHeight) { + return; + } + img.width = Math.max(1, Math.round(img.naturalWidth / renderScale)); + img.height = Math.max(1, Math.round(img.naturalHeight / renderScale)); + }; + + if (img.complete) { + applyDisplayScale(); + } else { + img.addEventListener("load", applyDisplayScale, { once: true }); + } + } + + return img; +} + +function moveCaretAwayFromTextNode(textNode) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + + const anchorOnNode = selection.anchorNode === textNode; + const focusOnNode = selection.focusNode === textNode; + if (!anchorOnNode && !focusOnNode) { + return false; + } + + try { + const range = document.createRange(); + range.setStartAfter(textNode); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return true; + } catch (error) { + // If caret relocation fails, proceed with replacement anyway. + return false; + } +} + +function setCaretAfterNode(node) { + const selection = window.getSelection(); + if (!selection) { + return; + } + + try { + const range = document.createRange(); + range.setStartAfter(node); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } catch (error) { + // Ignore caret update failures. + } +} + +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 getImageDataField(imageNode, datasetKey, attributeName) { + if (!imageNode || imageNode.tagName !== "IMG") { + return ""; + } + + const datasetValue = imageNode.dataset ? imageNode.dataset[datasetKey] : ""; + if (typeof datasetValue === "string" && datasetValue.trim()) { + return datasetValue; + } + + const attributeValue = imageNode.getAttribute(attributeName); + if (typeof attributeValue === "string" && attributeValue.trim()) { + return attributeValue; + } + + 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 (!sourceExpression) { + for (const candidate of candidates) { + if (isInlineLatexExpression(candidate)) { + sourceExpression = candidate; + break; + } + } + } + + 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 selectedSeed = readFormulaSeedFromImage(selectedImage); + if (selectedSeed) { + addFormulaToHistory(selectedSeed); + } + const sourceDocument = selectedSeed + ? selectedSeed.sourceDocument || "" + : lastComplexExpression || ""; + const sourceExpression = selectedSeed ? selectedSeed.sourceExpression || "" : ""; + const sourceMode = selectedSeed ? selectedSeed.sourceMode || "" : ""; + + return { + selection: getSelectionText(), + sourceMode, + sourceExpression, + sourceDocument, + complexSource: sourceDocument, + }; +} + +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 = []; + 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 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); + textNode.parentNode.removeChild(textNode); + if (movedCaret) { + setCaretAfterNode(img); + } + undoStack.push(() => { + if (!img.parentNode) { + return; + } + img.parentNode.insertBefore(textNode, img); + img.parentNode.removeChild(img); + }); + } + addFormulaToHistory(formulaSeed); + 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 selectedImage = getSelectedImageNode(); + const extractedInlineExpression = extractExpressionFromTemplate( + prefs.template, + latexExpression + ); + const sourceMode = extractedInlineExpression ? "inline" : "complex"; + const accessibleText = extractedInlineExpression || latexExpression; + 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); + 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); + } + }); + } + + addFormulaToHistory(formulaSeed); + lastComplexExpression = latexExpression; + 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 "getInsertComplexSeed": + return Promise.resolve(getInsertComplexSeed()); + case "getFormulaHistory": + return getFormulaHistoryForUi(); + case "hasLogReport": + return Promise.resolve(Boolean(document.getElementById(LOG_PANEL_ID))); + case "removeLogReport": { + const hadReport = Boolean(document.getElementById(LOG_PANEL_ID)); + removeLogPanel(); + return Promise.resolve({ ok: true, removed: hadReport }); + } + case "confirmSendWithLatexCheck": + return Promise.resolve(confirmSendWithLatexCheck()); + default: + return null; + } +}); diff --git a/content/accept.png b/content/accept.png deleted file mode 100644 index 89c8129..0000000 Binary files a/content/accept.png and /dev/null differ diff --git a/content/exclamation.png b/content/exclamation.png deleted file mode 100644 index c37bd06..0000000 Binary files a/content/exclamation.png and /dev/null differ 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: -

- - -

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 937ab3f..0000000 Binary files a/content/icon.png and /dev/null differ diff --git a/content/insert.js b/content/insert.js deleted file mode 100644 index c74e846..0000000 --- a/content/insert.js +++ /dev/null @@ -1,67 +0,0 @@ -function on_ok() { - var latex = document.getElementById("tblatex-expr").value; - var autodpi = document.getElementById("autodpi-checkbox").checked; - var font_px = document.getElementById("fontpx").value; - window.arguments[0](latex, autodpi, font_px); -} - -window.addEventListener("load", function (event) { - var template = window.arguments[1]; - var selection = window.arguments[2]; - populate(template, selection); - var autodpi = prefs.getBoolPref("autodpi"); - document.getElementById("autodpi-checkbox").checked = autodpi; - update_ui(autodpi); - var font_px = prefs.getIntPref("font_px"); - document.getElementById("fontpx").value = font_px; -}, false); - -function on_reset() { - var template = prefs.getCharPref("template"); - populate(template, null); -} - -function on_autodpi() { - var autodpi = document.getElementById("autodpi-checkbox").checked; - update_ui(autodpi); -} - -var prefs = Components.classes["@mozilla.org/preferences-service;1"] - .getService(Components.interfaces.nsIPrefService) - .getBranch("tblatex."); - -function populate(template, selection) { - var marker = "__REPLACE_ME__"; - var oldmarker = "__REPLACEME__"; - var start = template.indexOf(marker); - if (start < 0) { - start = template.indexOf(oldmarker); - if (start > -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 @@ - - - - - + + diff --git a/ui/insert.js b/ui/insert.js new file mode 100644 index 0000000..23a2ca9 --- /dev/null +++ b/ui/insert.js @@ -0,0 +1,367 @@ +"use strict"; + +const marker = "__REPLACE_ME__"; +const oldMarker = "__REPLACEME__"; + +let prefs = null; +let tabId = null; +let formulaHistory = []; +let saveDialogSizeTimer = 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 { + selection: "", + sourceMode: "", + sourceExpression: "", + sourceDocument: "", + complexSource: "", + }; + } + + try { + const seed = await browser.tabs.sendMessage(tabIdValue, { + command: "getInsertComplexSeed", + }); + + 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 : "", + }; + } + } catch (error) { + // Fall back to legacy selection command below. + } + + try { + 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: "", + }; + } +} + +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"); + const historySection = document.getElementById("historySection"); + + 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; + historySection.hidden = !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; + 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) { + 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 [seed, history] = await Promise.all([ + getSelection(tabId), + getFormulaHistory(tabId), + ]); + + formulaHistory = history; + renderFormulaHistory(); + applySeedToEditor(seed); +} + +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)}`); + } +} + +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, ""); + } +}); + +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(); +}); + +window.addEventListener("resize", () => { + scheduleDialogSizeSave(); +}); + +window.addEventListener("beforeunload", () => { + if (saveDialogSizeTimer !== null) { + clearTimeout(saveDialogSizeTimer); + saveDialogSizeTimer = null; + } + saveCurrentDialogSize().catch(() => {}); +}); + +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..efdcf4b --- /dev/null +++ b/ui/options.css @@ -0,0 +1,89 @@ +* { + 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; +} + +.hint { + margin: 8px 0 0 0; + color: #444; +} diff --git a/ui/options.html b/ui/options.html new file mode 100644 index 0000000..fb0ca58 --- /dev/null +++ b/ui/options.html @@ -0,0 +1,109 @@ + + + + + + LaTeX It! Options + + + +
+

LaTeX It! Options

+ +
+

Executables

+ + +
+ +
+
+ +
+

Sandbox Helper Fallback

+

Runtime: detecting...

+ + +
+ +
+

Start helper with: python3 helper/tblatex_helper.py

+
+ +
+

Formula Rendering

+ + + +
+ +
+

Sending

+ +
+ +
+

History

+ +
+ +
+

Template

+

Use __REPLACE_ME__ where your LaTeX expression should be inserted.

+ +
+ +
+

Debugging

+ + + +
+ +
+ + +
+ +

+
+ + + + diff --git a/ui/options.js b/ui/options.js new file mode 100644 index 0000000..d260a08 --- /dev/null +++ b/ui/options.js @@ -0,0 +1,163 @@ +"use strict"; + +const FIELDS = [ + "latexPath", + "dvipngPath", + "helperFallbackEnabled", + "helperUrl", + "autodpi", + "fontPx", + "renderScale", + "log", + "debug", + "warnOnUnconvertedLatex", + "persistFormulaHistory", + "keepTempFiles", + "template", +]; + +const NUMBER_DEFAULTS = Object.freeze({ + fontPx: 16, + renderScale: 4, +}); + +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); + 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 ?? ""; + } + } + + 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."); + } +} + +async function updateRuntimeInfo() { + const runtimeInfoElement = document.getElementById("runtimeInfo"); + try { + const runtimeInfo = await browser.runtime.sendMessage({ command: "getRuntimeInfo" }); + if (runtimeInfo && runtimeInfo.sandboxed) { + runtimeInfoElement.textContent = `Runtime: sandboxed (${runtimeInfo.sandboxType}). Helper fallback is recommended.`; + } else { + runtimeInfoElement.textContent = "Runtime: not sandboxed. Helper fallback is optional."; + } + } catch (error) { + runtimeInfoElement.textContent = `Runtime: unavailable (${String(error)})`; + } +} + +async function testHelper() { + const prefs = readPrefsFromForm(); + await browser.runtime.sendMessage({ + command: "setPrefs", + prefs: { + helperUrl: prefs.helperUrl, + helperFallbackEnabled: prefs.helperFallbackEnabled, + }, + }); + + const status = await browser.runtime.sendMessage({ command: "testHelper" }); + if (status && status.ok) { + setStatus(`Helper reachable at ${status.url}.`); + } else { + setStatus(`Helper unreachable at ${(status && status.url) || "configured URL"}.`); + } +} + +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)}`); + }); +}); + +document.getElementById("testHelper").addEventListener("click", () => { + testHelper().catch((error) => { + setStatus(`Helper test failed: ${String(error)}`); + }); +}); + +Promise.all([loadPrefs(), updateRuntimeInfo()]).catch((error) => { + setStatus(`Unable to load options: ${String(error)}`); +});