use crate::error::{Error, Result};
use katex::Opts;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, trace};
pub struct LatexRenderer {
opts: Opts,
cache: Arc<Mutex<HashMap<String, String>>>,
enable_cache: bool,
display_delimiter_start: String,
display_delimiter_end: String,
inline_delimiter_start: String,
inline_delimiter_end: String,
}
impl LatexRenderer {
pub fn new() -> Self {
Self::default()
}
pub fn with_cache() -> Self {
Self {
enable_cache: true,
..Self::default()
}
}
pub fn with_delimiters<S1, S2, S3, S4>(
display_start: S1,
display_end: S2,
inline_start: S3,
inline_end: S4,
) -> Self
where
S1: Into<String>,
S2: Into<String>,
S3: Into<String>,
S4: Into<String>,
{
Self {
display_delimiter_start: display_start.into(),
display_delimiter_end: display_end.into(),
inline_delimiter_start: inline_start.into(),
inline_delimiter_end: inline_end.into(),
..Self::default()
}
}
pub fn render<S: AsRef<str>>(&self, latex: S) -> Result<String> {
let latex = latex.as_ref();
trace!("Rendering LaTeX: {}", latex);
if self.enable_cache {
let cache = self.cache.lock();
if let Some(cached) = cache.get(latex) {
trace!("Cache hit for LaTeX");
return Ok(cached.clone());
}
}
let html =
katex::render(latex).map_err(|e| Error::latex_render(format!("KaTeX error: {}", e)))?;
if self.enable_cache {
let mut cache = self.cache.lock();
cache.insert(latex.to_string(), html.clone());
}
debug!("Rendered LaTeX equation");
Ok(html)
}
pub fn render_display<S: AsRef<str>>(&self, latex: S) -> Result<String> {
let latex = latex.as_ref();
let html = katex::render(latex)
.map_err(|e| Error::latex_render(format!("KaTeX display mode error: {}", e)))?;
debug!("Rendered display mode LaTeX");
Ok(html)
}
pub fn render_inline<S: AsRef<str>>(&self, latex: S) -> Result<String> {
let latex = latex.as_ref();
let html = katex::render(latex)
.map_err(|e| Error::latex_render(format!("KaTeX inline mode error: {}", e)))?;
debug!("Rendered inline mode LaTeX");
Ok(html)
}
pub fn render_from_text<S: AsRef<str>>(&self, text: S) -> Result<String> {
let text = text.as_ref();
let mut result = String::new();
let mut pos = 0;
while pos < text.len() {
let remaining = &text[pos..];
let next_pre = remaining.find("<pre").map(|p| (p, "pre"));
let next_code = remaining.find("<code").map(|p| (p, "code"));
let next_display = remaining
.find(&self.display_delimiter_start)
.map(|p| (p, "display"));
let next_inline = remaining
.find(&self.inline_delimiter_start)
.map(|p| (p, "inline"));
let earliest = [next_pre, next_code, next_display, next_inline]
.into_iter()
.flatten()
.min_by_key(|(p, _)| *p);
let (offset, kind) = match earliest {
Some(e) => e,
None => {
result.push_str(remaining);
break;
}
};
result.push_str(&remaining[..offset]);
match kind {
"pre" => {
let close = "</pre>";
if let Some(end) = remaining[offset..].find(close) {
let skip_end = offset + end + close.len();
result.push_str(&remaining[offset..skip_end]);
pos += skip_end;
} else {
result.push_str(&remaining[offset..]);
break;
}
}
"code" => {
let close = "</code>";
if let Some(end) = remaining[offset..].find(close) {
let skip_end = offset + end + close.len();
result.push_str(&remaining[offset..skip_end]);
pos += skip_end;
} else {
result.push_str(&remaining[offset..]);
break;
}
}
"display" => {
let after_open = offset + self.display_delimiter_start.len();
if let Some(end) = remaining[after_open..].find(&self.display_delimiter_end) {
let end = after_open + end;
let latex = &remaining[after_open..end];
match self.render_display(latex) {
Ok(html) => {
result.push_str(&html);
pos += end + self.display_delimiter_end.len();
}
Err(_) => {
result.push_str(&self.display_delimiter_start);
pos += after_open;
}
}
} else {
result.push_str(&remaining[offset..offset + 1]);
pos += 1;
}
}
"inline" => {
let after_open = offset + self.inline_delimiter_start.len();
if let Some(end) = remaining[after_open..].find(&self.inline_delimiter_end) {
let end = after_open + end;
let latex = &remaining[after_open..end];
match self.render_inline(latex) {
Ok(html) => {
result.push_str(&html);
pos += end + self.inline_delimiter_end.len();
}
Err(_) => {
result.push_str(&self.inline_delimiter_start);
pos += after_open;
}
}
} else {
result.push_str(&remaining[offset..offset + 1]);
pos += 1;
}
}
_ => unreachable!(),
}
}
Ok(result)
}
pub fn validate<S: AsRef<str>>(&self, latex: S) -> bool {
let latex = latex.as_ref();
katex::render(latex).is_ok()
}
pub fn opts(&self) -> &Opts {
&self.opts
}
pub fn set_opts(&mut self, opts: Opts) {
self.opts = opts;
}
pub fn set_cache(&mut self, enable: bool) {
self.enable_cache = enable;
}
pub fn clear_cache(&self) {
let mut cache = self.cache.lock();
cache.clear();
debug!("LaTeX cache cleared");
}
pub fn cache_size(&self) -> usize {
self.cache.lock().len()
}
pub fn set_delimiters<S1, S2, S3, S4>(
&mut self,
display_start: S1,
display_end: S2,
inline_start: S3,
inline_end: S4,
) where
S1: Into<String>,
S2: Into<String>,
S3: Into<String>,
S4: Into<String>,
{
self.display_delimiter_start = display_start.into();
self.display_delimiter_end = display_end.into();
self.inline_delimiter_start = inline_start.into();
self.inline_delimiter_end = inline_end.into();
}
}
impl Default for LatexRenderer {
fn default() -> Self {
Self {
opts: Opts::default(),
cache: Arc::new(Mutex::new(HashMap::new())),
enable_cache: false,
display_delimiter_start: "$$".to_string(),
display_delimiter_end: "$$".to_string(),
inline_delimiter_start: "$".to_string(),
inline_delimiter_end: "$".to_string(),
}
}
}
#[derive(Default)]
pub struct LatexDocumentRenderer {
renderer: LatexRenderer,
}
impl LatexDocumentRenderer {
pub fn new() -> Self {
Self {
renderer: LatexRenderer::new(),
}
}
pub fn with_cache() -> Self {
Self {
renderer: LatexRenderer::with_cache(),
}
}
pub fn render<S: AsRef<str>>(&self, latex: S) -> Result<String> {
self.renderer.render_from_text(latex)
}
pub fn renderer(&self) -> &LatexRenderer {
&self.renderer
}
pub fn renderer_mut(&mut self) -> &mut LatexRenderer {
&mut self.renderer
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_simple_latex() {
let renderer = LatexRenderer::new();
let result = renderer.render("E = mc^2").unwrap();
assert!(result.contains("E"));
}
#[test]
fn test_render_with_cache() {
let renderer = LatexRenderer::with_cache();
let result1 = renderer.render("E = mc^2").unwrap();
let result2 = renderer.render("E = mc^2").unwrap();
assert_eq!(result1, result2);
assert!(renderer.cache_size() > 0);
}
#[test]
fn test_render_from_text_preserves_content_before_delimiter() {
let renderer = LatexRenderer::new();
let text = "Hello world $E = mc^2$ more text";
let result = renderer.render_from_text(text).unwrap();
assert!(
result.contains("Hello world"),
"Content before $ was dropped"
);
assert!(
result.contains("more text"),
"Content after closing $ was dropped"
);
}
#[test]
fn test_render_from_text_dollar_sign_in_code_block() {
let renderer = LatexRenderer::new();
let html = "<h2>Docker</h2><pre><code>docker run -d \\\n -p 8080:8080\n</code></pre><h2>Nginx</h2><pre><code>proxy_set_header Host $host;\nproxy_set_header X-Real-IP $remote_addr;\n</code></pre><h2>TLS</h2>";
let result = renderer.render_from_text(html).unwrap();
assert!(
result.contains("$host"),
"$host should be preserved in code block"
);
assert!(
result.contains("$remote_addr"),
"$remote_addr should be preserved in code block"
);
assert!(result.contains("Docker"), "Docker section was dropped");
assert!(result.contains("Nginx"), "Nginx section was dropped");
assert!(result.contains("TLS"), "TLS section was dropped");
}
#[test]
fn test_render_from_text_display_mode() {
let renderer = LatexRenderer::new();
let text = "Before $$x^2$$ after";
let result = renderer.render_from_text(text).unwrap();
assert!(result.contains("Before"), "Content before $$ was dropped");
assert!(result.contains("after"), "Content after $$ was dropped");
}
#[test]
fn test_validate_latex() {
let renderer = LatexRenderer::new();
assert!(renderer.validate("E = mc^2"));
assert!(!renderer.validate("\\invalid latex"));
}
#[test]
fn test_latex_document_renderer() {
let doc_renderer = LatexDocumentRenderer::new();
let result = doc_renderer.render("E = mc^2").unwrap();
assert!(result.contains("E"));
}
}