use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::PathBuf;
use tempfile::TempDir;
fn get_cache_dir() -> PathBuf {
let cache_dir = directories::ProjectDirs::from("", "", "map2fig")
.map(|dirs| dirs.cache_dir().to_path_buf())
.unwrap_or_else(|| {
std::env::home_dir()
.map(|h| h.join(".cache/map2fig"))
.unwrap_or_else(|| PathBuf::from(".map2fig_cache"))
})
.join("latex");
let _ = std::fs::create_dir_all(&cache_dir);
cache_dir
}
fn get_cache_key(latex_str: &str, font_size: u32) -> String {
let mut hasher = Sha256::new();
hasher.update(latex_str.as_bytes());
hasher.update(font_size.to_string().as_bytes());
format!("{:x}.png", hasher.finalize())
}
fn check_pdflatex() -> bool {
std::process::Command::new("which")
.arg("pdflatex")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn check_pdf2svg() -> bool {
std::process::Command::new("which")
.arg("pdf2svg")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn check_convert() -> bool {
std::process::Command::new("which")
.arg("convert")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn render_latex_to_hires_png(
latex_str: &str,
font_size_pt: u32,
dpi: u32,
) -> Option<RenderedLatex> {
let hash_value = {
let mut hasher = Sha256::new();
hasher.update(latex_str.as_bytes());
hasher.update(font_size_pt.to_string().as_bytes());
hasher.update(dpi.to_string().as_bytes());
format!("{:x}", hasher.finalize())
};
let cache_key = format!("{}_{}.png", hash_value, dpi);
let cache_path = get_cache_dir().join(&cache_key);
let temp_dir = match TempDir::new() {
Ok(dir) => dir,
Err(_) => return None,
};
let temp_path = temp_dir.path();
let latex_doc = format!(
r#"\documentclass[{}pt]{{standalone}}
\usepackage{{amsmath,amssymb,mathtools,xcolor}}
\setlength{{\parskip}}{{0pt}}
\setlength{{\parindent}}{{0pt}}
\pagestyle{{empty}}
\begin{{document}}
\vspace*{{-0.5em}}
{}
\vspace*{{-0.5em}}
\end{{document}}"#,
font_size_pt, latex_str
);
let tex_path = temp_path.join("document.tex");
match std::fs::File::create(&tex_path) {
Ok(mut file) => {
if file.write_all(latex_doc.as_bytes()).is_err() {
return None;
}
}
Err(_) => return None,
}
let output = std::process::Command::new("pdflatex")
.arg("-interaction=nonstopmode")
.arg("-no-shell-escape")
.arg(&tex_path)
.current_dir(temp_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let pdf_path = temp_path.join("document.pdf");
let png_path = temp_path.join("document.png");
let dpi_str = dpi.to_string();
let output = std::process::Command::new("pdftoppm")
.arg("-singlefile")
.arg("-png")
.arg("-r")
.arg(&dpi_str) .arg(&pdf_path)
.arg(png_path.with_extension(""))
.output()
.ok()?;
if !output.status.success() {
return None;
}
let cropped_path = temp_path.join("document_cropped.png");
let trim_output = std::process::Command::new("convert")
.arg(&png_path)
.arg("-trim")
.arg("+repage") .arg("-bordercolor")
.arg("white")
.arg("-border")
.arg("1x1") .arg(&cropped_path)
.output();
let trim_success = trim_output
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false);
let cropped_exists = cropped_path.exists();
let final_png_path = if trim_success && cropped_exists {
cropped_path
} else {
png_path.clone()
};
let image_data = std::fs::read(&final_png_path).ok()?;
let (width, height) = extract_png_dimensions(&image_data).ok()?;
let _ = std::fs::write(&cache_path, &image_data);
Some(RenderedLatex {
image_data,
width,
height,
})
}
#[derive(Clone, Debug)]
pub struct RenderedLatex {
pub image_data: Vec<u8>,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug)]
pub struct RenderedLatexSvg {
pub svg_data: String,
pub width: f64,
pub height: f64,
}
pub fn render_latex_to_svg(latex_str: &str, font_size_pt: u32) -> Option<RenderedLatexSvg> {
if !check_pdf2svg() && !check_convert() {
return None;
}
let pdf_bytes = render_latex_to_pdf(latex_str, font_size_pt)?;
let temp_dir = match TempDir::new() {
Ok(dir) => dir,
Err(_) => return None,
};
let temp_path = temp_dir.path();
let pdf_path = temp_path.join("document.pdf");
let svg_path = temp_path.join("document.svg");
match std::fs::write(&pdf_path, &pdf_bytes) {
Ok(_) => {}
Err(_) => return None,
}
if check_pdf2svg() {
let output = std::process::Command::new("pdf2svg")
.arg(&pdf_path)
.arg(&svg_path)
.output()
.ok()?;
if output.status.success()
&& let Ok(svg_data) = std::fs::read_to_string(&svg_path)
&& let Some((width, height)) = extract_svg_dimensions(&svg_data)
{
return Some(RenderedLatexSvg {
svg_data,
width,
height,
});
}
}
if check_convert() {
let output = std::process::Command::new("convert")
.arg("-density")
.arg("150")
.arg(format!("{}[0]", pdf_path.display())) .arg(&svg_path)
.output()
.ok()?;
if output.status.success()
&& let Ok(svg_data) = std::fs::read_to_string(&svg_path)
&& let Some((width, height)) = extract_svg_dimensions(&svg_data)
{
return Some(RenderedLatexSvg {
svg_data,
width,
height,
});
}
}
None
}
pub fn render_latex_to_svg_png(latex_str: &str, font_size_pt: u32) -> Option<RenderedLatex> {
let _ = render_latex_to_svg(latex_str, font_size_pt)?;
None
}
fn extract_svg_dimensions(svg_data: &str) -> Option<(f64, f64)> {
let start = svg_data.find("viewBox=\"")?;
let after_viewbox = &svg_data[start + 9..];
let end = after_viewbox.find("\"")?;
let viewbox_str = &after_viewbox[..end];
let parts: Vec<&str> = viewbox_str.split_whitespace().collect();
if parts.len() < 4 {
return None;
}
let width = parts[2].parse::<f64>().ok()?;
let height = parts[3].parse::<f64>().ok()?;
Some((width, height))
}
pub fn render_latex_to_pdf_tectonic(latex_str: &str, font_size_pt: u32) -> Option<Vec<u8>> {
let temp_dir = match TempDir::new() {
Ok(dir) => dir,
Err(_) => return None,
};
let temp_path = temp_dir.path();
let pdf_path = temp_path.join("document.pdf");
let latex_doc = format!(
r#"\documentclass[{}pt]{{standalone}}
\usepackage{{amsmath,amssymb,mathtools,xcolor}}
\pagestyle{{empty}}
\begin{{document}}
\rule{{0pt}}{{0.6cm}}\\
{}
\end{{document}}"#,
font_size_pt, latex_str
);
let tex_path = temp_path.join("document.tex");
match std::fs::File::create(&tex_path) {
Ok(mut file) => {
if file.write_all(latex_doc.as_bytes()).is_err() {
return None;
}
}
Err(_) => return None,
}
let output = std::process::Command::new("tectonic")
.arg(&tex_path)
.current_dir(temp_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
std::fs::read(&pdf_path).ok()
}
pub fn render_latex_to_pdf(latex_str: &str, font_size_pt: u32) -> Option<Vec<u8>> {
if !check_pdflatex() {
return None;
}
let temp_dir = match TempDir::new() {
Ok(dir) => dir,
Err(_) => return None,
};
let temp_path = temp_dir.path();
let latex_doc = format!(
r#"\documentclass[{}pt]{{standalone}}
\usepackage{{amsmath,amssymb,mathtools,xcolor}}
\pagestyle{{empty}}
\begin{{document}}
\rule{{0pt}}{{0.6cm}}\\
{}
\end{{document}}"#,
font_size_pt, latex_str
);
let tex_path = temp_path.join("document.tex");
match std::fs::File::create(&tex_path) {
Ok(mut file) => {
if file.write_all(latex_doc.as_bytes()).is_err() {
return None;
}
}
Err(_) => return None,
}
let output = std::process::Command::new("pdflatex")
.arg("-interaction=nonstopmode")
.arg("-no-shell-escape")
.arg(&tex_path)
.current_dir(temp_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let pdf_path = temp_path.join("document.pdf");
std::fs::read(&pdf_path).ok()
}
pub fn render_latex_to_png(latex_str: &str, font_size_pt: u32) -> Option<RenderedLatex> {
let cache_key = get_cache_key(latex_str, font_size_pt);
let cache_path = get_cache_dir().join(&cache_key);
if cache_path.exists()
&& let Ok(image_data) = std::fs::read(&cache_path)
&& let Ok((width, height)) = extract_png_dimensions(&image_data)
{
return Some(RenderedLatex {
image_data,
width,
height,
});
}
let temp_dir = match TempDir::new() {
Ok(dir) => dir,
Err(_) => return None,
};
let temp_path = temp_dir.path();
let latex_doc = format!(
r#"\documentclass[{}pt]{{standalone}}
\usepackage{{amsmath,amssymb,mathtools,xcolor}}
\setlength{{\parskip}}{{0pt}}
\setlength{{\parindent}}{{0pt}}
\pagestyle{{empty}}
\begin{{document}}
\vspace*{{-0.5em}}
{}
\vspace*{{-0.5em}}
\end{{document}}"#,
font_size_pt, latex_str
);
let tex_path = temp_path.join("document.tex");
match std::fs::File::create(&tex_path) {
Ok(mut file) => {
if file.write_all(latex_doc.as_bytes()).is_err() {
return None;
}
}
Err(_) => return None,
}
let output = std::process::Command::new("pdflatex")
.arg("-interaction=nonstopmode")
.arg("-no-shell-escape")
.arg(&tex_path)
.current_dir(temp_path)
.output()
.ok()?;
if !output.status.success() {
eprintln!("pdflatex compilation failed for: {}", latex_str);
return None;
}
let pdf_path = temp_path.join("document.pdf");
let png_path = temp_path.join("document.png");
let output = std::process::Command::new("pdftoppm")
.arg("-singlefile")
.arg("-png")
.arg("-r")
.arg("150") .arg(&pdf_path)
.arg(png_path.with_extension(""))
.output()
.ok()?;
if !output.status.success() {
eprintln!("pdftoppm failed for: {}", latex_str);
return None;
}
let image_data = std::fs::read(&png_path).ok()?;
let (width, height) = extract_png_dimensions(&image_data).ok()?;
let _ = std::fs::write(&cache_path, &image_data);
Some(RenderedLatex {
image_data,
width,
height,
})
}
fn extract_png_dimensions(png_data: &[u8]) -> Result<(u32, u32), String> {
if png_data.len() < 24 {
return Err("PNG too short".to_string());
}
if &png_data[0..8] != b"\x89PNG\r\n\x1a\n" {
return Err("Invalid PNG signature".to_string());
}
let width = u32::from_be_bytes([png_data[16], png_data[17], png_data[18], png_data[19]]);
let height = u32::from_be_bytes([png_data[20], png_data[21], png_data[22], png_data[23]]);
Ok((width, height))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_key_uniqueness() {
let key1 = get_cache_key("$x^2$", 12);
let key2 = get_cache_key("$x^2$", 12);
assert_eq!(key1, key2);
let key3 = get_cache_key("$x^3$", 12);
assert_ne!(key1, key3);
let key4 = get_cache_key("$x^2$", 14);
assert_ne!(key1, key4);
}
#[test]
fn test_png_dimension_parsing() {
let png = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x08, 0x02, 0x00, 0x00, 0x00, 0x00,
];
let (w, h) = extract_png_dimensions(&png).unwrap();
assert_eq!(w, 2);
assert_eq!(h, 3);
}
#[cfg(not(miri))]
#[test]
fn test_svg_rendering() {
if !check_pdflatex() || (!check_pdf2svg() && !check_convert()) {
eprintln!("Skipping SVG test: pdflatex or conversion tools not available");
return;
}
if let Some(svg) = render_latex_to_svg("$K$", 10) {
assert!(svg.width > 0.0, "SVG width should be > 0");
assert!(svg.height > 0.0, "SVG height should be > 0");
assert!(!svg.svg_data.is_empty(), "SVG data should not be empty");
eprintln!(
"SVG render test: {}x{} with {} bytes",
svg.width,
svg.height,
svg.svg_data.len()
);
} else {
eprintln!("SVG rendering returned None");
}
}
#[test]
fn test_svg_dimension_extraction() {
let svg_data = r#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
<text>Test</text>
</svg>"#;
if let Some((w, h)) = extract_svg_dimensions(svg_data) {
assert_eq!(w, 100.0);
assert_eq!(h, 50.0);
} else {
panic!("Failed to extract SVG dimensions");
}
}
}