diff --git a/mathrender.el b/mathrender.el index f68a18a..52910f8 100644 --- a/mathrender.el +++ b/mathrender.el @@ -51,9 +51,9 @@ the next idle cycle after changing this.") (defvar mathrender--binary nil "Cached path to the mathrender executable.") -(defvar-local mathrender--active-ov nil +(defvar-local mathrender--active-overlay nil "Overlay whose display is hidden while the cursor is inside it, or nil.") -(defvar-local mathrender--dirty nil +(defvar-local mathrender--dirty-region nil "Cons (BEG . END) of the buffer region that changed and needs rescanning.") ;;; Overlay helpers @@ -61,41 +61,41 @@ the next idle cycle after changing this.") (defun mathrender--clear-previews () "Remove all mathrender overlays from the current buffer." (let ((inhibit-modification-hooks t)) - (dolist (ov (overlays-in (point-min) (point-max))) - (when (overlay-get ov 'mathrender-preview) - (when (eq ov mathrender--active-ov) - (setq mathrender--active-ov nil)) - (mathrender--cancel-process ov) - (delete-overlay ov))))) + (dolist (overlay (overlays-in (point-min) (point-max))) + (when (overlay-get overlay 'mathrender-preview) + (when (eq overlay mathrender--active-overlay) + (setq mathrender--active-overlay nil)) + (mathrender--cancel-process overlay) + (delete-overlay overlay))))) (defun mathrender--overlay-at (pos) "Return the mathrender overlay at POS, or nil." - (cl-find-if (lambda (ov) (overlay-get ov 'mathrender-preview)) + (cl-find-if (lambda (overlay) (overlay-get overlay 'mathrender-preview)) (overlays-at pos))) -(defun mathrender--make-overlay (beg end) +(defun mathrender--make-overlay (begin end) "Create a mathrender overlay from BEG to END with no display yet." ;; front-advance=t: text inserted before the opening delimiter stays outside. - (let ((ov (make-overlay beg end nil t nil)) + (let ((overlay (make-overlay begin end nil t nil)) (inhibit-modification-hooks t)) - (overlay-put ov 'mathrender-preview t) - (overlay-put ov 'priority 100) - (overlay-put ov 'keymap + (overlay-put overlay 'mathrender-preview t) + (overlay-put overlay 'priority 100) + (overlay-put overlay 'keymap (let ((map (make-sparse-keymap))) (define-key map [mouse-1] (lambda () (interactive) (let ((inhibit-modification-hooks t)) - (overlay-put ov 'display nil)))) + (overlay-put overlay 'display nil)))) map)) - ov)) + overlay)) ;;; Math parsing -(defun mathrender--parse-overlay (ov) +(defun mathrender--parse-overlay (overlay) "Return (MATH-TEXT . INLINE-P) for OV's current buffer content, or nil." (let ((raw (buffer-substring-no-properties - (overlay-start ov) (overlay-end ov)))) + (overlay-start overlay) (overlay-end overlay)))) (cond ((string-match "\\`\\$\\$\\(\\(?:.\\|\n\\)+\\)\\$\\$\\'" raw) (cons (string-trim (match-string 1 raw)) nil)) @@ -135,25 +135,25 @@ Accounts for text-scale-mode (C-x C-+/C--) so math scales with the buffer." ;;; Async rendering -(defun mathrender--cancel-process (ov) - "Kill any in-flight render process attached to OV." +(defun mathrender--cancel-process (overlay) + "Kill any in-flight render process attached to OVERLAY." (let ((inhibit-modification-hooks t)) - (when-let ((proc (overlay-get ov 'mathrender-process))) - (when (process-live-p proc) - (set-process-sentinel proc nil) - (when-let* ((err-buf (process-get proc 'stderr-buf)) + (when-let ((process (overlay-get overlay 'mathrender-process))) + (when (process-live-p process) + (set-process-sentinel process nil) + (when-let* ((err-buf (process-get process 'stderr-buf)) (err-proc (and (buffer-live-p err-buf) (get-buffer-process err-buf)))) (set-process-sentinel err-proc nil)) - (delete-process proc)) - (let ((out-buf (process-buffer proc)) - (err-buf (process-get proc 'stderr-buf))) + (delete-process process)) + (let ((out-buf (process-buffer process)) + (err-buf (process-get process 'stderr-buf))) (when (buffer-live-p out-buf) (let ((kill-buffer-query-functions nil)) (kill-buffer out-buf))) (when (buffer-live-p err-buf) (let ((kill-buffer-query-functions nil)) (kill-buffer err-buf)))) - (overlay-put ov 'mathrender-process nil)))) + (overlay-put overlay 'mathrender-process nil)))) (defun mathrender--any-active-p () "Return t if mathrender-mode is active in any buffer." @@ -161,14 +161,14 @@ Accounts for text-scale-mode (C-x C-+/C--) so math scales with the buffer." (buffer-local-value 'mathrender-mode buf)) (buffer-list))) -(defun mathrender--render-overlay (ov) - "Start an async render for OV; update its display when the process finishes." - (when (and mathrender--binary (overlay-buffer ov)) - (mathrender--cancel-process ov) - (when-let ((parsed (mathrender--parse-overlay ov))) +(defun mathrender--render-overlay (overlay) + "Start an async render for OVERLAY; update its display when the process finishes." + (when (and mathrender--binary (overlay-buffer overlay)) + (mathrender--cancel-process overlay) + (when-let ((parsed (mathrender--parse-overlay overlay))) (let* ((math-text (car parsed)) (inline-p (cdr parsed)) - (target-buf (overlay-buffer ov)) + (target-buf (overlay-buffer overlay)) (font-size (with-current-buffer target-buf (mathrender--font-size-pt))) (fg-color (with-current-buffer target-buf (mathrender--fg-color))) (cmd `(,mathrender--binary @@ -194,26 +194,26 @@ Accounts for text-scale-mode (C-x C-+/C--) so math scales with the buffer." (unwind-protect (when (memq (process-status p) '(exit signal)) (when (and (buffer-live-p target-buf) - (overlay-buffer ov)) + (overlay-buffer overlay)) (let ((svg (when (string-prefix-p "finished" event) (with-current-buffer out-buf (buffer-string))))) (if (and svg (> (length svg) 0)) (let ((img (create-image svg 'svg t :ascent 'center))) - (overlay-put ov 'mathrender-image img) - (overlay-put ov 'mathrender-font-size font-size) - (overlay-put ov 'mathrender-color fg-color) + (overlay-put overlay 'mathrender-image img) + (overlay-put overlay 'mathrender-font-size font-size) + (overlay-put overlay 'mathrender-color fg-color) (with-current-buffer target-buf - (unless (eq ov mathrender--active-ov) - (overlay-put ov 'display img)))) - (overlay-put ov 'mathrender-image nil) + (unless (eq overlay mathrender--active-overlay) + (overlay-put overlay 'display img)))) + (overlay-put overlay 'mathrender-image nil) (with-current-buffer target-buf - (overlay-put ov 'display nil)))) - (overlay-put ov 'mathrender-process nil))) + (overlay-put overlay 'display nil)))) + (overlay-put overlay 'mathrender-process nil))) (let ((kill-buffer-query-functions nil)) (when (buffer-live-p out-buf) (kill-buffer out-buf)) (when (buffer-live-p err-buf) (kill-buffer err-buf)))))))) (process-put proc 'stderr-buf err-buf) - (overlay-put ov 'mathrender-process proc) + (overlay-put overlay 'mathrender-process proc) (process-send-string proc math-text) (process-send-eof proc)) (error @@ -224,16 +224,18 @@ Accounts for text-scale-mode (C-x C-+/C--) so math scales with the buffer." ;;; Block detection -(defun mathrender--scan (beg end) +(defun mathrender--scan (begin end) "Scan BEG..END for math blocks; create and render overlays for new ones." - (let ((inhibit-modification-hooks t)) + (let ((inhibit-modification-hooks t) + (b (min begin end)) + (e (max begin end))) (save-excursion - (goto-char beg) + (goto-char b) (let ((pairs `(("$$" "\\$\\$" nil) (,(concat "\\" "[") ,(concat "\\\\" "]") nil) (,(concat "\\" "(") ,(concat "\\\\" ")") t) ("$" "\\$" t)))) - (while (re-search-forward "\\$\\$\\|\\\\\\[\\|\\\\(\\|\\$" end t) + (while (re-search-forward "\\$\\$\\|\\\\\\[\\|\\\\(\\|\\$" e t) (let* ((opener (match-string-no-properties 0)) (full-beg (match-beginning 0)) (math-beg (match-end 0)) diff --git a/src/main.rs b/src/main.rs index 29edd7f..c7a59c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use std::io::{self, Read}; -use std::panic; use std::path::PathBuf; +use std::sync::OnceLock; use chrono::Datelike; use clap::Parser; @@ -30,18 +30,62 @@ struct Args { color: String, } -/// tex2typst-rs 0.1 panics on unsupported tokens like \begin{...}. -fn safe_tex2typst(s: &str) -> String { - if s.trim().is_empty() { - return s.to_owned(); +#[derive(Debug)] +enum Error { + Compile(String), + NoPages, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Compile(msg) => write!(f, "Typst compile error(s):\n {}", msg), + Error::NoPages => write!(f, "Typst produced no pages"), + } } - match panic::catch_unwind(|| tex2typst_rs::tex2typst(s)) { - Ok(r) => r, - Err(_) => s.to_owned(), +} + +impl std::error::Error for Error {} + +struct GlobalFonts { + book: LazyHash, + fonts: Vec, +} + +fn get_global_fonts() -> &'static GlobalFonts { + static FONTS: OnceLock = OnceLock::new(); + FONTS.get_or_init(|| { + let mut book = FontBook::new(); + let mut fonts = Vec::new(); + + for font_data in typst_assets::fonts() { + let bytes = Bytes::new(font_data); + for index in 0u32.. { + match Font::new(bytes.clone(), index) { + Some(font) => { + book.push(font.info().clone()); + fonts.push(font); + } + None => break, + } + } + } + GlobalFonts { book: LazyHash::new(book), fonts } + }) +} + +fn safe_tex2typst(s: &str, buf: &mut String) { + let s = s.trim(); + if s.is_empty() { + return; } + // Assertion enforcing pre-condition bounds + debug_assert!(s.len() < 10000, "Input to safe_tex2typst is abnormally large"); + + buf.push_str(&tex2typst_rs::tex2typst(s)); } -fn convert_matrix_env(env: &str, content: &str) -> String { +fn env_matrix_convert(env: &str, content: &str, out_typst_string: &mut String) { let delim = match env { "pmatrix" | "pmatrix*" => "(", "bmatrix" | "bmatrix*" => "[", @@ -53,55 +97,109 @@ fn convert_matrix_env(env: &str, content: &str) -> String { _ => "(", }; - let rows: Vec> = content - .split("\\\\") - .map(|row| { - row.split('&') - .map(|cell| safe_tex2typst(cell.trim())) - .collect() - }) - .filter(|row: &Vec| !row.is_empty() && !row[0].is_empty()) - .collect(); - - let mat_content = rows.iter() - .map(|row| row.join(", ")) - .collect::>() - .join("; "); - match delim { - "" => format!("mat(delim: #none, {mat_content})"), - "(" => format!("mat({mat_content})"), - d => format!("mat(delim: \"{d}\", {mat_content})"), + "" => out_typst_string.push_str("mat(delim: #none, "), + "(" => out_typst_string.push_str("mat("), + d => { out_typst_string.push_str("mat(delim: \""); out_typst_string.push_str(d); out_typst_string.push_str("\", "); } + } + + let mut first_row = true; + for row in content.split("\\\\").take(256) { + if row.trim().is_empty() { continue; } + if !first_row { out_typst_string.push_str("; "); } + first_row = false; + + let mut first_cell = true; + for cell in row.split('&').take(256) { + let cell = cell.trim(); + if !first_cell { out_typst_string.push_str(", "); } + first_cell = false; + safe_tex2typst(cell, out_typst_string); + } + } + out_typst_string.push(')'); +} + +fn env_cases_convert(content: &str, out_typst_string: &mut String) { + out_typst_string.push_str("cases("); + let mut first_row = true; + for row in content.split("\\\\").take(256) { + let row = row.trim(); + if row.is_empty() { continue; } + if !first_row { out_typst_string.push_str(", "); } + first_row = false; + + let mut parts = row.splitn(2, '&'); + let expr = parts.next().expect("Cases row must have content").trim(); + let cond = parts.next().map(|s| s.trim()); + + safe_tex2typst(expr, out_typst_string); + if let Some(c) = cond { + out_typst_string.push_str(", "); + safe_tex2typst(c, out_typst_string); + } + } + out_typst_string.push(')'); +} + +fn process_latex_env(env: &str, content: &str, result: &mut String) { + debug_assert!(!env.is_empty(), "Environment string cannot be empty"); + + match env { + "pmatrix" | "pmatrix*" | + "bmatrix" | "bmatrix*" | + "vmatrix" | "vmatrix*" | + "Bmatrix" | "Bmatrix*" | + "Vmatrix" | "Vmatrix*" | + "matrix" | "matrix*" | + "smallmatrix" => env_matrix_convert(env, content, result), + + "cases" | "dcases" => env_cases_convert(content, result), + + "align" | "align*" | + "aligned" | "alignat" | "alignat*" | + "gather" | "gather*" | + "multline" | "multline*" | + "split" | "equation" | "equation*" => safe_tex2typst(content, result), + + _ => { + result.push_str("\\begin{"); + result.push_str(env); + result.push('}'); + result.push_str(content); + result.push_str("\\end{"); + result.push_str(env); + result.push('}'); + } } } -fn convert_cases_env(content: &str) -> String { - let rows: Vec = content - .split("\\\\") - .filter_map(|row| { - let row = row.trim(); - if row.is_empty() { return None; } - let parts: Vec<&str> = row.splitn(2, '&').collect(); - Some(match parts.as_slice() { - [expr, cond] => format!("{} {}", - safe_tex2typst(expr.trim()), - safe_tex2typst(cond.trim())), - [expr] => safe_tex2typst(expr.trim()), - _ => safe_tex2typst(row), - }) - }) - .collect(); - format!("cases({})", rows.join(", ")) +fn find_env_end<'a>(rest: &'a str, env: &str) -> Option<(&'a str, &'a str)> { + let end_search = "\\end{"; + let mut search_rest = rest; + let mut current_offset = 0; + + while let Some(end_begin) = search_rest.find(end_search) { + let tag_content_start = end_begin + end_search.len(); + if search_rest[tag_content_start..].starts_with(env) && + search_rest[tag_content_start + env.len()..].starts_with('}') { + let content = &rest[..current_offset + end_begin]; + let advance = current_offset + end_begin + end_search.len() + env.len() + 1; + return Some((content, &rest[advance..])); + } + let advance = end_begin + end_search.len(); + search_rest = &search_rest[advance..]; + current_offset += advance; + } + None } -/// tex2typst-rs panics on \begin{...} environments; we intercept and convert -/// them to Typst equivalents before calling the library. fn latex_to_typst(input: &str) -> String { - let mut result = String::new(); + let mut result = String::with_capacity(input.len() * 2); let mut rest = input; while let Some(begin_pos) = rest.find("\\begin{") { - result.push_str(&safe_tex2typst(&rest[..begin_pos])); + safe_tex2typst(&rest[..begin_pos], &mut result); rest = &rest[begin_pos + 7..]; let Some(brace_end) = rest.find('}') else { @@ -111,45 +209,29 @@ fn latex_to_typst(input: &str) -> String { let env = &rest[..brace_end]; rest = &rest[brace_end + 1..]; - let end_tag = format!("\\end{{{env}}}"); - let Some(end_pos) = rest.find(end_tag.as_str()) else { - result.push_str(&format!("\\begin{{{env}}}")); - continue; - }; - let content = &rest[..end_pos]; - rest = &rest[end_pos + end_tag.len()..]; - - let converted = match env { - "pmatrix" | "pmatrix*" | - "bmatrix" | "bmatrix*" | - "vmatrix" | "vmatrix*" | - "Bmatrix" | "Bmatrix*" | - "Vmatrix" | "Vmatrix*" | - "matrix" | "matrix*" | - "smallmatrix" => convert_matrix_env(env, content), - - "cases" | "dcases" => convert_cases_env(content), - - "align" | "align*" | - "aligned" | "alignat" | "alignat*" | - "gather" | "gather*" | - "multline" | "multline*" | - "split" | "equation" | "equation*" => safe_tex2typst(content), - - _ => format!("\\begin{{{env}}}{content}\\end{{{env}}}"), - }; - result.push_str(&converted); + if let Some((content, new_rest)) = find_env_end(rest, env) { + process_latex_env(env, content, &mut result); + rest = new_rest; + } else { + result.push_str("\\begin{"); + result.push_str(env); + result.push('}'); + } } - result.push_str(&safe_tex2typst(rest)); + safe_tex2typst(rest, &mut result); result } +fn get_global_library() -> &'static LazyHash { + static LIBRARY: OnceLock> = OnceLock::new(); + LIBRARY.get_or_init(|| { + LazyHash::new(typst_library::LibraryBuilder::default().build()) + }) +} + struct MathWorld { - library: LazyHash, - book: LazyHash, - fonts: Vec, - source: Source, + source: Source, } impl MathWorld { @@ -157,34 +239,15 @@ impl MathWorld { let main_id = FileId::new(None, VirtualPath::new("/main.typ")); let source = Source::new(main_id, source_text); - let mut book = FontBook::new(); - let mut fonts = Vec::new(); - - for font_data in typst_assets::fonts() { - let bytes = Bytes::new(font_data); - for index in 0u32.. { - match Font::new(bytes.clone(), index) { - Some(font) => { - book.push(font.info().clone()); - fonts.push(font); - } - None => break, - } - } - } - Self { - library: LazyHash::new(typst_library::LibraryBuilder::default().build()), - book: LazyHash::new(book), - fonts, source, } } } impl World for MathWorld { - fn library(&self) -> &LazyHash { &self.library } - fn book(&self) -> &LazyHash { &self.book } + fn library(&self) -> &LazyHash { get_global_library() } + fn book(&self) -> &LazyHash { &get_global_fonts().book } fn main(&self) -> FileId { self.source.id() } fn source(&self, id: FileId) -> FileResult { @@ -200,7 +263,7 @@ impl World for MathWorld { } fn font(&self, index: usize) -> Option { - self.fonts.get(index).cloned() + get_global_fonts().fonts.get(index).cloned() } fn today(&self, offset: Option) -> Option { @@ -231,7 +294,7 @@ fn render_to_svg( display: bool, font_size: f64, color: &str, -) -> Result> { +) -> Result { let typst_math = if is_typst { input.to_owned() @@ -245,10 +308,10 @@ fn render_to_svg( let document: PagedDocument = compiled.output.map_err(|errors| { let msgs: Vec = errors.iter().map(|e| e.message.to_string()).collect(); - format!("Typst compile error(s):\n {}", msgs.join("\n ")) + Error::Compile(msgs.join("\n ")) })?; - let page = document.pages.first().ok_or("Typst produced no pages")?; + let page = document.pages.first().ok_or(Error::NoPages)?; Ok(typst_svg::svg(page)) }