431 lines
13 KiB
Rust
431 lines
13 KiB
Rust
//! LaTeX output formatter with styling and package management
|
|
|
|
use super::LineData;
|
|
|
|
/// LaTeX document formatter
|
|
#[derive(Clone)]
|
|
pub struct LaTeXFormatter {
|
|
packages: Vec<String>,
|
|
document_class: String,
|
|
preamble: String,
|
|
numbered_equations: bool,
|
|
custom_delimiters: Option<(String, String)>,
|
|
}
|
|
|
|
impl LaTeXFormatter {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
packages: vec!["amsmath".to_string(), "amssymb".to_string()],
|
|
document_class: "article".to_string(),
|
|
preamble: String::new(),
|
|
numbered_equations: false,
|
|
custom_delimiters: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_packages(mut self, packages: Vec<String>) -> Self {
|
|
self.packages = packages;
|
|
self
|
|
}
|
|
|
|
pub fn add_package(mut self, package: String) -> Self {
|
|
if !self.packages.contains(&package) {
|
|
self.packages.push(package);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn document_class(mut self, class: String) -> Self {
|
|
self.document_class = class;
|
|
self
|
|
}
|
|
|
|
pub fn preamble(mut self, preamble: String) -> Self {
|
|
self.preamble = preamble;
|
|
self
|
|
}
|
|
|
|
pub fn numbered_equations(mut self, numbered: bool) -> Self {
|
|
self.numbered_equations = numbered;
|
|
self
|
|
}
|
|
|
|
pub fn custom_delimiters(mut self, start: String, end: String) -> Self {
|
|
self.custom_delimiters = Some((start, end));
|
|
self
|
|
}
|
|
|
|
/// Format plain LaTeX content
|
|
pub fn format(&self, latex: &str) -> String {
|
|
// Clean up LaTeX if needed
|
|
let cleaned = self.clean_latex(latex);
|
|
|
|
// Apply custom delimiters if specified
|
|
if let Some((start, end)) = &self.custom_delimiters {
|
|
format!("{}{}{}", start, cleaned, end)
|
|
} else {
|
|
cleaned
|
|
}
|
|
}
|
|
|
|
/// Format line data to LaTeX
|
|
pub fn format_lines(&self, lines: &[LineData]) -> String {
|
|
let mut output = String::new();
|
|
let mut in_align = false;
|
|
|
|
for line in lines {
|
|
match line.line_type.as_str() {
|
|
"text" => {
|
|
if in_align {
|
|
output.push_str("\\end{align*}\n\n");
|
|
in_align = false;
|
|
}
|
|
output.push_str(&self.escape_text(&line.text));
|
|
output.push_str("\n\n");
|
|
}
|
|
"math" | "equation" => {
|
|
let latex = line.latex.as_ref().unwrap_or(&line.text);
|
|
|
|
if self.numbered_equations {
|
|
output.push_str("\\begin{equation}\n");
|
|
output.push_str(latex.trim());
|
|
output.push_str("\n\\end{equation}\n\n");
|
|
} else {
|
|
output.push_str("\\[\n");
|
|
output.push_str(latex.trim());
|
|
output.push_str("\n\\]\n\n");
|
|
}
|
|
}
|
|
"inline_math" => {
|
|
let latex = line.latex.as_ref().unwrap_or(&line.text);
|
|
output.push_str(&format!("${}$", latex.trim()));
|
|
}
|
|
"align" => {
|
|
if !in_align {
|
|
output.push_str("\\begin{align*}\n");
|
|
in_align = true;
|
|
}
|
|
let latex = line.latex.as_ref().unwrap_or(&line.text);
|
|
output.push_str(latex.trim());
|
|
output.push_str(" \\\\\n");
|
|
}
|
|
"table" => {
|
|
output.push_str(&self.format_table(&line.text));
|
|
output.push_str("\n\n");
|
|
}
|
|
_ => {
|
|
output.push_str(&line.text);
|
|
output.push_str("\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
if in_align {
|
|
output.push_str("\\end{align*}\n");
|
|
}
|
|
|
|
output.trim().to_string()
|
|
}
|
|
|
|
/// Format complete LaTeX document
|
|
pub fn format_document(&self, content: &str) -> String {
|
|
let mut doc = String::new();
|
|
|
|
// Document class
|
|
doc.push_str(&format!("\\documentclass{{{}}}\n\n", self.document_class));
|
|
|
|
// Packages
|
|
for package in &self.packages {
|
|
doc.push_str(&format!("\\usepackage{{{}}}\n", package));
|
|
}
|
|
doc.push_str("\n");
|
|
|
|
// Custom preamble
|
|
if !self.preamble.is_empty() {
|
|
doc.push_str(&self.preamble);
|
|
doc.push_str("\n\n");
|
|
}
|
|
|
|
// Begin document
|
|
doc.push_str("\\begin{document}\n\n");
|
|
|
|
// Content
|
|
doc.push_str(content);
|
|
doc.push_str("\n\n");
|
|
|
|
// End document
|
|
doc.push_str("\\end{document}\n");
|
|
|
|
doc
|
|
}
|
|
|
|
/// Clean and normalize LaTeX
|
|
fn clean_latex(&self, latex: &str) -> String {
|
|
let mut cleaned = latex.to_string();
|
|
|
|
// Remove excessive whitespace
|
|
while cleaned.contains(" ") {
|
|
cleaned = cleaned.replace(" ", " ");
|
|
}
|
|
|
|
// Normalize line breaks
|
|
cleaned = cleaned.replace("\r\n", "\n");
|
|
|
|
// Ensure proper spacing around operators
|
|
for op in &["=", "+", "-", r"\times", r"\div"] {
|
|
let spaced = format!(" {} ", op);
|
|
cleaned = cleaned.replace(op, &spaced);
|
|
}
|
|
|
|
// Remove duplicate spaces again
|
|
while cleaned.contains(" ") {
|
|
cleaned = cleaned.replace(" ", " ");
|
|
}
|
|
|
|
cleaned.trim().to_string()
|
|
}
|
|
|
|
/// Escape special LaTeX characters in text
|
|
fn escape_text(&self, text: &str) -> String {
|
|
text.replace('\\', r"\\")
|
|
.replace('{', r"\{")
|
|
.replace('}', r"\}")
|
|
.replace('$', r"\$")
|
|
.replace('%', r"\%")
|
|
.replace('_', r"\_")
|
|
.replace('&', r"\&")
|
|
.replace('#', r"\#")
|
|
.replace('^', r"\^")
|
|
.replace('~', r"\~")
|
|
}
|
|
|
|
/// Format table to LaTeX tabular environment
|
|
fn format_table(&self, table: &str) -> String {
|
|
let rows: Vec<&str> = table.lines().collect();
|
|
if rows.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
// Determine number of columns from first row
|
|
let num_cols = rows[0].split('|').filter(|s| !s.is_empty()).count();
|
|
let col_spec = "c".repeat(num_cols);
|
|
|
|
let mut output = format!("\\begin{{tabular}}{{{}}}\n", col_spec);
|
|
output.push_str("\\hline\n");
|
|
|
|
for (i, row) in rows.iter().enumerate() {
|
|
let cells: Vec<&str> = row
|
|
.split('|')
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
|
|
output.push_str(&cells.join(" & "));
|
|
output.push_str(" \\\\\n");
|
|
|
|
if i == 0 {
|
|
output.push_str("\\hline\n");
|
|
}
|
|
}
|
|
|
|
output.push_str("\\hline\n");
|
|
output.push_str("\\end{tabular}");
|
|
|
|
output
|
|
}
|
|
|
|
/// Convert inline LaTeX to display math
|
|
pub fn inline_to_display(&self, latex: &str) -> String {
|
|
if self.numbered_equations {
|
|
format!("\\begin{{equation}}\n{}\n\\end{{equation}}", latex.trim())
|
|
} else {
|
|
format!("\\[\n{}\n\\]", latex.trim())
|
|
}
|
|
}
|
|
|
|
/// Add equation label
|
|
pub fn add_label(&self, latex: &str, label: &str) -> String {
|
|
format!("{}\n\\label{{{}}}", latex.trim(), label)
|
|
}
|
|
}
|
|
|
|
impl Default for LaTeXFormatter {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Styled LaTeX formatter with predefined templates
|
|
#[allow(dead_code)]
|
|
pub struct StyledLaTeXFormatter {
|
|
base: LaTeXFormatter,
|
|
style: LaTeXStyle,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LaTeXStyle {
|
|
Article,
|
|
Report,
|
|
Book,
|
|
Beamer,
|
|
Minimal,
|
|
}
|
|
|
|
impl StyledLaTeXFormatter {
|
|
pub fn new(style: LaTeXStyle) -> Self {
|
|
let base = match style {
|
|
LaTeXStyle::Article => LaTeXFormatter::new()
|
|
.document_class("article".to_string())
|
|
.with_packages(vec![
|
|
"amsmath".to_string(),
|
|
"amssymb".to_string(),
|
|
"graphicx".to_string(),
|
|
"hyperref".to_string(),
|
|
]),
|
|
LaTeXStyle::Report => LaTeXFormatter::new()
|
|
.document_class("report".to_string())
|
|
.with_packages(vec![
|
|
"amsmath".to_string(),
|
|
"amssymb".to_string(),
|
|
"graphicx".to_string(),
|
|
"hyperref".to_string(),
|
|
"geometry".to_string(),
|
|
]),
|
|
LaTeXStyle::Book => LaTeXFormatter::new()
|
|
.document_class("book".to_string())
|
|
.with_packages(vec![
|
|
"amsmath".to_string(),
|
|
"amssymb".to_string(),
|
|
"graphicx".to_string(),
|
|
"hyperref".to_string(),
|
|
"geometry".to_string(),
|
|
"fancyhdr".to_string(),
|
|
]),
|
|
LaTeXStyle::Beamer => LaTeXFormatter::new()
|
|
.document_class("beamer".to_string())
|
|
.with_packages(vec![
|
|
"amsmath".to_string(),
|
|
"amssymb".to_string(),
|
|
"graphicx".to_string(),
|
|
]),
|
|
LaTeXStyle::Minimal => LaTeXFormatter::new()
|
|
.document_class("article".to_string())
|
|
.with_packages(vec!["amsmath".to_string()]),
|
|
};
|
|
|
|
Self { base, style }
|
|
}
|
|
|
|
pub fn format_document(
|
|
&self,
|
|
content: &str,
|
|
title: Option<&str>,
|
|
author: Option<&str>,
|
|
) -> String {
|
|
let mut preamble = String::new();
|
|
|
|
if let Some(t) = title {
|
|
preamble.push_str(&format!("\\title{{{}}}\n", t));
|
|
}
|
|
if let Some(a) = author {
|
|
preamble.push_str(&format!("\\author{{{}}}\n", a));
|
|
}
|
|
if title.is_some() || author.is_some() {
|
|
preamble.push_str("\\date{\\today}\n");
|
|
}
|
|
|
|
let formatter = self.base.clone().preamble(preamble);
|
|
let mut doc = formatter.format_document(content);
|
|
|
|
// Add maketitle after \begin{document} if we have title/author
|
|
if title.is_some() || author.is_some() {
|
|
doc = doc.replace(
|
|
"\\begin{document}\n\n",
|
|
"\\begin{document}\n\n\\maketitle\n\n",
|
|
);
|
|
}
|
|
|
|
doc
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::output::BoundingBox;
|
|
|
|
#[test]
|
|
fn test_format_simple() {
|
|
let formatter = LaTeXFormatter::new();
|
|
let result = formatter.format("E = mc^2");
|
|
assert!(result.contains("mc^2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_document() {
|
|
let formatter = LaTeXFormatter::new();
|
|
let doc = formatter.format_document("E = mc^2");
|
|
|
|
assert!(doc.contains(r"\documentclass{article}"));
|
|
assert!(doc.contains(r"\usepackage{amsmath}"));
|
|
assert!(doc.contains(r"\begin{document}"));
|
|
assert!(doc.contains("mc^2"));
|
|
assert!(doc.contains(r"\end{document}"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_escape_text() {
|
|
let formatter = LaTeXFormatter::new();
|
|
let result = formatter.escape_text("Price: $100 & 50%");
|
|
assert!(result.contains(r"\$100"));
|
|
assert!(result.contains(r"\&"));
|
|
assert!(result.contains(r"\%"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_inline_to_display() {
|
|
let formatter = LaTeXFormatter::new();
|
|
let result = formatter.inline_to_display("x^2 + y^2 = r^2");
|
|
assert!(result.contains(r"\["));
|
|
assert!(result.contains(r"\]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_styled_formatter() {
|
|
let formatter = StyledLaTeXFormatter::new(LaTeXStyle::Article);
|
|
let doc = formatter.format_document("Content", Some("My Title"), Some("Author Name"));
|
|
|
|
assert!(doc.contains(r"\title{My Title}"));
|
|
assert!(doc.contains(r"\author{Author Name}"));
|
|
assert!(doc.contains(r"\maketitle"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_lines() {
|
|
let formatter = LaTeXFormatter::new();
|
|
let lines = vec![
|
|
LineData {
|
|
line_type: "text".to_string(),
|
|
text: "Introduction".to_string(),
|
|
latex: None,
|
|
bbox: BoundingBox::new(0.0, 0.0, 100.0, 20.0),
|
|
confidence: 0.95,
|
|
words: None,
|
|
},
|
|
LineData {
|
|
line_type: "equation".to_string(),
|
|
text: "E = mc^2".to_string(),
|
|
latex: Some(r"E = mc^2".to_string()),
|
|
bbox: BoundingBox::new(0.0, 25.0, 100.0, 30.0),
|
|
confidence: 0.98,
|
|
words: None,
|
|
},
|
|
];
|
|
|
|
let result = formatter.format_lines(&lines);
|
|
assert!(result.contains("Introduction"));
|
|
assert!(result.contains(r"\[") || result.contains(r"\begin{equation}"));
|
|
assert!(result.contains("mc^2"));
|
|
}
|
|
}
|