late2htm 0.1.0

A Rust library for converting LaTeX-formatted text into HTML with minimal styling.
Documentation
use crate::ast::LatexNode;
use regex::Regex;

pub fn parse_latex(input: &str) -> Vec<LatexNode> {
    let mut nodes = Vec::new();
    let mut remaining = input;

    let patterns: &[(Regex, fn(String) -> LatexNode)] = &[
        (Regex::new(r"\\section\{(.*?)\}").unwrap(), |c| LatexNode::Section(c)),
        (Regex::new(r"\\subsection\{(.*?)\}").unwrap(), |c| LatexNode::Subsection(c)),
        (Regex::new(r"\\subsubsection\{(.*?)\}").unwrap(), |c| LatexNode::Subsubsection(c)),
        (Regex::new(r"\\paragraph\{(.*?)\}").unwrap(), |c| LatexNode::Paragraph(c)),
        (Regex::new(r"\\subparagraph\{(.*?)\}").unwrap(), |c| LatexNode::Subparagraph(c)),
        (Regex::new(r"\\textbf\{(.*?)\}").unwrap(), |c| LatexNode::Bold(c)),
        (Regex::new(r"\\textit\{(.*?)\}").unwrap(), |c| LatexNode::Italic(c)),
        (Regex::new(r"\\underline\{(.*?)\}").unwrap(), |c| LatexNode::Underline(c)),
        (Regex::new(r"\\emph\{(.*?)\}").unwrap(), |c| LatexNode::Emphasized(c)),
        (Regex::new(r"\\url\{(.*?)\}").unwrap(), |c| LatexNode::Hyperlink { url: c.clone(), text: c }),
        (Regex::new(r"\\tableofcontents").unwrap(), |_| LatexNode::TableOfContents),
        (Regex::new(r"\\pagebreak").unwrap(), |_| LatexNode::PageBreak),
        (Regex::new(r"\\hrule").unwrap(), |_| LatexNode::HorizontalRule),
        (Regex::new(r"\$(.*?)\$").unwrap(), |c| LatexNode::InlineMath(c)),
        (Regex::new(r"\\\[(.*?)\\\]").unwrap(), |c| LatexNode::DisplayMath(c)),
    ];

    // Two-argument patterns
    let textcolor_re = Regex::new(r"\\textcolor\{(.*?)\}\{(.*?)\}").unwrap();
    let href_re = Regex::new(r"\\href\{(.*?)\}\{(.*?)\}").unwrap();

    let mut pos = 0;
    while pos < remaining.len() {
        let slice = &remaining[pos..];
        let mut matched = false;

        // Two-argument matches first
        if let Some(cap) = textcolor_re.captures(slice) {
            if let Some(mat) = cap.get(0) {
                let start = pos + mat.start();
                let end = pos + mat.end();
                if start > pos {
                    let text = &remaining[pos..start];
                    if !text.trim().is_empty() {
                        nodes.push(LatexNode::Text(text.to_string()));
                    }
                }
                nodes.push(LatexNode::Colored {
                    color: cap[1].to_string(),
                    content: cap[2].to_string(),
                });
                pos = end;
                matched = true;
                continue;
            }
        } else if let Some(cap) = href_re.captures(slice) {
            if let Some(mat) = cap.get(0) {
                let start = pos + mat.start();
                let end = pos + mat.end();
                if start > pos {
                    let text = &remaining[pos..start];
                    if !text.trim().is_empty() {
                        nodes.push(LatexNode::Text(text.to_string()));
                    }
                }
                nodes.push(LatexNode::Hyperlink {
                    url: cap[1].to_string(),
                    text: cap[2].to_string(),
                });
                pos = end;
                matched = true;
                continue;
            }
        }

        // Single-argument matches
        for (re, constructor) in patterns {
            if let Some(cap) = re.captures(slice) {
                if let Some(mat) = cap.get(0) {
                    let start = pos + mat.start();
                    let end = pos + mat.end();

                    if start > pos {
                        let text = &remaining[pos..start];
                        if !text.trim().is_empty() {
                            nodes.push(LatexNode::Text(text.to_string()));
                        }
                    }

                    let content = cap.get(1).map_or("", |m| m.as_str()).to_string();
                    nodes.push(constructor(content));
                    pos = end;
                    matched = true;
                    break;
                }
            }
        }

        if !matched {
            let rest = &remaining[pos..];
            if !rest.trim().is_empty() {
                nodes.push(LatexNode::Text(rest.to_string()));
            }
            break;
        }
    }

    nodes
}



fn extract_brace_content<'a>(line: &'a str, command: &str) -> Option<&'a str> {
    let prefix = format!("{}{{", command);
    if line.starts_with(&prefix) && line.ends_with('}') {
        Some(&line[prefix.len()..line.len() - 1])
    } else {
        None
    }
}

fn extract_two_brace_args<'a>(line: &'a str, command: &str) -> Option<(&'a str, &'a str)> {
    let re = Regex::new(&format!(r"{}\{{([^}}]+)\}}\{{([^}}]+)\}}", regex::escape(command))).ok()?;
    let caps = re.captures(line)?;
    Some((caps.get(1)?.as_str(), caps.get(2)?.as_str()))
}

fn extract_fontsize_command<'a>(line: &'a str) -> Option<(&'a str, &'a str)> {
    let re = Regex::new(r"\\fontsize\{([^}]+)\}\{[^}]+\}\\selectfont\s*(.+)").ok()?;
    let caps = re.captures(line)?;
    Some((caps.get(1)?.as_str(), caps.get(2)?.as_str()))
}

fn parse_newcommand(line: &str) -> Option<(String, usize, String)> {
    let re = Regex::new(r"\\newcommand\\(\\w+)(?:\[(\d+)\])?\{(.+)\}").ok()?;
    let caps = re.captures(line)?;
    let name = caps.get(1)?.as_str().to_string();
    let args = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
    let def = caps.get(3)?.as_str().to_string();
    Some((name, args, def))
}

fn parse_item_list(lines: &[&str], env: &str) -> (Vec<LatexNode>, usize) {
    let mut items = Vec::new();
    let mut i = 1;
    while i < lines.len() && !lines[i].trim().starts_with(&format!("\\end{{{}}}", env)) {
        let line = lines[i].trim();
        if line.starts_with("\\item") {
            let content = line.trim_start_matches("\\item").trim();
            items.push(LatexNode::ListItem(content.to_string()));
        }
        i += 1;
    }
    (items, i + 1)
}

fn parse_environment_block(lines: &[&str], env: &str) -> (Vec<String>, usize) {
    let mut content = Vec::new();
    let mut i = 1;
    while i < lines.len() && !lines[i].trim().starts_with(&format!("\\end{{{}}}", env)) {
        content.push(lines[i].to_string());
        i += 1;
    }
    (content, i + 1)
}

fn parse_description_list(lines: &[&str]) -> (Vec<(String, String)>, usize) {
    let mut items = Vec::new();
    let mut i = 1;
    let re = Regex::new(r"\\item\[(.*?)\](.*)").unwrap();
    while i < lines.len() && !lines[i].trim().starts_with("\\end{description}") {
        let line = lines[i].trim();
        if let Some(caps) = re.captures(line) {
            let label = caps.get(1).unwrap().as_str().trim().to_string();
            let content = caps.get(2).unwrap().as_str().trim().to_string();
            items.push((label, content));
        }
        i += 1;
    }
    (items, i + 1)
}

fn parse_tabular(lines: &[&str]) -> (LatexNode, usize) {
    let alignment_line = lines[0];
    let re_align = Regex::new(r"\\begin\{tabular\}\{([^}]+)\}").unwrap();
    let alignment = if let Some(caps) = re_align.captures(alignment_line) {
        caps.get(1).unwrap().as_str().chars().map(|c| c.to_string()).collect()
    } else {
        vec![]
    };
    let mut rows = Vec::new();
    let mut i = 1;
    while i < lines.len() && !lines[i].trim().starts_with("\\end{tabular}") {
        let line = lines[i].trim();
        if !line.is_empty() {
            let cols: Vec<String> = line.split('&').map(|s| s.trim().trim_end_matches("\\").to_string()).collect();
            rows.push(cols);
        }
        i += 1;
    }
    (LatexNode::Table {
        alignment,
        rows,
        caption: None,
    }, i + 1)
}

fn parse_figure(lines: &[&str]) -> (LatexNode, usize) {
    let mut content = Vec::new();
    let mut caption = None;
    let mut i = 1;
    while i < lines.len() && !lines[i].trim().starts_with("\\end{figure}") {
        let line = lines[i].trim();
        if line.starts_with("\\caption") {
            if let Some(c) = extract_brace_content(line, "\\caption") {
                caption = Some(c.to_string());
            }
        } else if line.starts_with("\\includegraphics") {
            if let Some(path) = extract_brace_content(line, "\\includegraphics") {
                content.push(LatexNode::Image {
                    path: path.to_string(),
                    width: None,
                    height: None,
                    caption: None,
                });
            }
        } else if !line.is_empty() {
            content.push(LatexNode::Text(line.to_string()));
        }
        i += 1;
    }
    (LatexNode::Figure {
        content,
        caption,
    }, i + 1)
}

fn parse_bibliography(lines: &[&str]) -> (Vec<LatexNode>, usize) {
    let mut items = Vec::new();
    let mut i = 1;
    while i < lines.len() && !lines[i].trim().starts_with("\\end{thebibliography}") {
        let line = lines[i].trim();
        if line.starts_with("\\bibitem") {
            let re = Regex::new(r"\\bibitem\{([^}]+)\}(.*)").unwrap();
            if let Some(caps) = re.captures(line) {
                let key = caps.get(1).unwrap().as_str().to_string();
                let content = caps.get(2).unwrap().as_str().to_string();
                items.push(LatexNode::BibItem {
                    key,
                    content: vec![LatexNode::Text(content)],
                });
            }
        }
        i += 1;
    }
    (items, i + 1)
}