use crate::document::{Block, Document, Theme};
pub fn render_equation(latex: &str) -> Result<String, String> {
let opts = katex::Opts::builder()
.display_mode(true)
.build()
.unwrap();
katex::render_with_opts(latex, opts).map_err(|e| e.to_string())
}
pub fn render_inline_math(latex: &str) -> Result<String, String> {
let opts = katex::Opts::builder()
.display_mode(false)
.build()
.unwrap();
katex::render_with_opts(latex, opts).map_err(|e| e.to_string())
}
pub fn render_chat_text(text: &str) -> String {
process_inline_math(text)
}
pub fn render_reply_content(text: &str, step_id: usize) -> String {
let mut result = String::with_capacity(text.len() * 2);
result.push_str("<div class=\"reply-content\">");
let mut eq_sub = 0usize;
let mut rest = text;
loop {
match rest.find("$$") {
Some(start) => {
let prose = &rest[..start];
let prose_trimmed = prose.trim();
if !prose_trimmed.is_empty() {
result.push_str("<p class=\"reply-prose\">");
result.push_str(&process_inline_math(prose_trimmed));
result.push_str("</p>");
}
let after_open = &rest[start + 2..];
match after_open.find("$$") {
Some(end) => {
let latex = after_open[..end].trim();
eq_sub += 1;
if latex.is_empty() {
rest = &after_open[end + 2..];
continue;
}
match render_equation(latex) {
Ok(rendered) => {
result.push_str(&format!(
"<div class=\"equation-card reply-equation\" data-latex=\"{}\" data-eq-num=\"{}.{}\"><div class=\"equation-content\">{}</div><span class=\"equation-number\">({}.{})</span></div>",
html_escape(latex), step_id, eq_sub, rendered, step_id, eq_sub
));
}
Err(err_msg) => {
result.push_str("<div class=\"equation-card reply-equation error-card\">");
result.push_str("<code>");
result.push_str(&html_escape(latex));
result.push_str("</code>");
result.push_str("<div class=\"error-msg\">");
result.push_str(&html_escape(&err_msg));
result.push_str("</div></div>");
}
}
rest = &after_open[end + 2..];
}
None => {
let remaining = rest.trim();
if !remaining.is_empty() {
result.push_str("<p class=\"reply-prose\">");
result.push_str(&process_inline_math(remaining));
result.push_str("</p>");
}
break;
}
}
}
None => {
let remaining = rest.trim();
if !remaining.is_empty() {
result.push_str("<p class=\"reply-prose\">");
result.push_str(&process_inline_math(remaining));
result.push_str("</p>");
}
break;
}
}
}
result.push_str("</div>");
result
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub(crate) fn process_inline_math(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut i = 0;
let mut text_buf = String::new();
let flush_text = |buf: &mut String, out: &mut String| {
if !buf.is_empty() {
out.push_str(&html_escape(buf));
buf.clear();
}
};
while i < len {
if i + 1 < len && chars[i] == '$' && chars[i + 1] == '$' {
text_buf.push('$');
text_buf.push('$');
i += 2;
continue;
}
if chars[i] == '$' {
let start = i + 1;
let mut end = None;
let mut j = start;
while j < len {
if j + 1 < len && chars[j] == '$' && chars[j + 1] == '$' {
j += 2;
continue;
}
if chars[j] == '$' {
end = Some(j);
break;
}
j += 1;
}
if let Some(end_pos) = end {
let latex: String = chars[start..end_pos].iter().collect();
if latex.is_empty() {
text_buf.push('$');
i += 1;
continue;
}
flush_text(&mut text_buf, &mut result);
match render_inline_math(&latex) {
Ok(html) => result.push_str(&html),
Err(_) => {
result.push_str("<code class=\"error-inline\">");
result.push('$');
result.push_str(&html_escape(&latex));
result.push('$');
result.push_str("</code>");
}
}
i = end_pos + 1;
} else {
text_buf.push('$');
i += 1;
}
} else {
text_buf.push(chars[i]);
i += 1;
}
}
flush_text(&mut text_buf, &mut result);
result
}
fn render_block(block: &Block) -> String {
let mut eq_num: usize = 0;
render_block_numbered(block, &mut eq_num)
}
fn render_block_numbered(block: &Block, eq_number: &mut usize) -> String {
match block {
Block::Step {
id,
title,
equations,
notes,
is_result,
} => {
let class = if *is_result { "step result" } else { "step" };
let data_latex = equations
.first()
.map(|eq| html_escape(eq))
.unwrap_or_default();
let mut html = format!(
"<div class=\"{}\" data-step-id=\"{}\" data-step-title=\"{}\" data-latex=\"{}\">",
class,
id,
html_escape(title),
data_latex
);
html.push_str(&format!(
"<div class=\"step-header\"><span class=\"step-number\">{}.</span><span class=\"step-title\">{}</span></div>",
id,
process_inline_math(title)
));
for eq in equations {
*eq_number += 1;
match render_equation(eq) {
Ok(rendered) => {
html.push_str(&format!(
"<div class=\"equation-card\"><div class=\"equation-content\">{}</div><span class=\"equation-number\">({})</span></div>",
rendered, eq_number
));
}
Err(err_msg) => {
html.push_str("<div class=\"equation-card error-card\">");
html.push_str("<code>");
html.push_str(&html_escape(eq));
html.push_str("</code>");
html.push_str("<div class=\"error-msg\">");
html.push_str(&html_escape(&err_msg));
html.push_str("</div></div>");
}
}
}
for note in notes {
html.push_str("<div class=\"note\">");
html.push_str(&process_inline_math(note));
html.push_str("</div>");
}
html.push_str("</div>");
html
}
Block::Prose { content } => {
format!(
"<div class=\"prose\">{}</div>",
process_inline_math(content)
)
}
Block::Divider => "<hr class=\"divider\">".to_string(),
}
}
pub fn render_blocks_html(doc: &Document) -> String {
let mut html = String::with_capacity(doc.blocks.len() * 1024);
let mut eq_number: usize = 0;
for block in &doc.blocks {
html.push_str(&render_block_numbered(block, &mut eq_number));
}
html
}
pub fn render_full_page(doc: &Document) -> String {
let theme_class = match doc.theme {
Theme::Dark => "dark",
Theme::Light => "light",
};
let blocks_html = render_blocks_html(doc);
let title_escaped = html_escape(&doc.title);
format!(
r#"<!DOCTYPE html>
<html lang="en" data-theme="{theme_class}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title_escaped} — cliboard</title>
<link rel="stylesheet" href="/katex.min.css">
<link rel="stylesheet" href="/viewer.css">
</head>
<body>
<div id="board">
<header id="board-header">
<h1>{title_escaped}</h1>
</header>
<main id="board-content">
{blocks_html}
</main>
</div>
<script src="/viewer.js"></script>
</body>
</html>"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::Document;
#[test]
fn test_render_equation_success() {
let result = render_equation("E = mc^2");
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("katex"));
}
#[test]
fn test_render_equation_error() {
let result = render_equation(r"\");
assert!(result.is_err());
}
#[test]
fn test_render_inline_math() {
let result = render_inline_math("x^2");
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("katex"));
assert!(!html.contains("katex-display"));
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("<div>"), "<div>");
assert_eq!(html_escape("a&b"), "a&b");
assert_eq!(html_escape("x=\"y\""), "x="y"");
}
#[test]
fn test_process_inline_math() {
let result = process_inline_math("The value $x^2$ is positive");
assert!(result.contains("katex"));
assert!(result.contains("The value "));
assert!(result.contains(" is positive"));
}
#[test]
fn test_process_inline_math_no_math() {
let result = process_inline_math("No math here");
assert_eq!(result, "No math here");
}
#[test]
fn test_process_inline_math_error() {
let result = process_inline_math("Bad: $\\$ end");
assert!(result.contains("error-inline"));
}
#[test]
fn test_render_blocks_html_step() {
let doc = Document {
title: "Test".to_string(),
theme: Theme::Dark,
blocks: vec![Block::Step {
id: 1,
title: "First Step".to_string(),
equations: vec!["E = mc^2".to_string()],
notes: vec!["Energy equation".to_string()],
is_result: false,
}],
};
let html = render_blocks_html(&doc);
assert!(html.contains("data-step-id=\"1\""));
assert!(html.contains("First Step"));
assert!(html.contains("equation-card"));
assert!(html.contains("note"));
}
#[test]
fn test_render_blocks_html_result_step() {
let doc = Document {
title: "Test".to_string(),
theme: Theme::Dark,
blocks: vec![Block::Step {
id: 2,
title: "Result".to_string(),
equations: vec!["F = ma".to_string()],
notes: vec![],
is_result: true,
}],
};
let html = render_blocks_html(&doc);
assert!(html.contains("class=\"step result\""));
}
#[test]
fn test_render_blocks_html_prose() {
let doc = Document {
title: "Test".to_string(),
theme: Theme::Dark,
blocks: vec![Block::Prose {
content: "Some text with $x$ inline".to_string(),
}],
};
let html = render_blocks_html(&doc);
assert!(html.contains("class=\"prose\""));
assert!(html.contains("katex"));
}
#[test]
fn test_render_blocks_html_divider() {
let doc = Document {
title: "Test".to_string(),
theme: Theme::Dark,
blocks: vec![Block::Divider],
};
let html = render_blocks_html(&doc);
assert!(html.contains("<hr class=\"divider\">"));
}
#[test]
fn test_render_full_page() {
let doc = Document {
title: "Physics".to_string(),
theme: Theme::Dark,
blocks: vec![Block::Divider],
};
let html = render_full_page(&doc);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Physics"));
assert!(html.contains("data-theme=\"dark\""));
assert!(html.contains("viewer.js"));
}
#[test]
fn test_render_full_page_light_theme() {
let doc = Document {
title: "Math".to_string(),
theme: Theme::Light,
blocks: vec![],
};
let html = render_full_page(&doc);
assert!(html.contains("data-theme=\"light\""));
}
#[test]
fn test_render_equation_simple_display() {
let result = render_equation("a + b = c");
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("katex-display"));
}
#[test]
fn test_render_equation_invalid_latex_error_card() {
let result = render_equation(r"\invalidcommand{");
assert!(result.is_err());
}
#[test]
fn test_render_block_step_html_structure() {
let block = Block::Step {
id: 3,
title: "Test Step".to_string(),
equations: vec!["x = 1".to_string()],
notes: vec!["A note".to_string()],
is_result: false,
};
let html = render_block(&block);
assert!(html.contains("class=\"step\""));
assert!(html.contains("data-step-id=\"3\""));
assert!(html.contains("data-step-title=\"Test Step\""));
assert!(html.contains("step-number"));
assert!(html.contains("3."));
assert!(html.contains("step-title"));
assert!(html.contains("equation-card"));
assert!(html.contains("class=\"note\""));
assert!(html.contains("A note"));
}
#[test]
fn test_render_block_prose() {
let block = Block::Prose {
content: "Simple paragraph".to_string(),
};
let html = render_block(&block);
assert!(html.contains("class=\"prose\""));
assert!(html.contains("Simple paragraph"));
}
#[test]
fn test_render_block_divider() {
let block = Block::Divider;
let html = render_block(&block);
assert_eq!(html, "<hr class=\"divider\">");
}
#[test]
fn test_render_block_result_class() {
let block = Block::Step {
id: 1,
title: "Final".to_string(),
equations: vec!["y = 42".to_string()],
notes: vec![],
is_result: true,
};
let html = render_block(&block);
assert!(html.contains("class=\"step result\""));
}
#[test]
fn test_render_block_multiple_equations() {
let block = Block::Step {
id: 1,
title: "Multi".to_string(),
equations: vec!["a = 1".to_string(), "b = 2".to_string(), "c = 3".to_string()],
notes: vec![],
is_result: false,
};
let html = render_block(&block);
let count = html.matches("equation-card").count();
assert_eq!(count, 3);
}
#[test]
fn test_render_block_notes_with_inline_math() {
let block = Block::Step {
id: 1,
title: "Notes".to_string(),
equations: vec!["x = 1".to_string()],
notes: vec!["Where $x$ is a variable".to_string()],
is_result: false,
};
let html = render_block(&block);
assert!(html.contains("katex"));
assert!(html.contains("class=\"note\""));
}
#[test]
fn test_html_escape_single_quote() {
assert_eq!(html_escape("it's"), "it's");
}
#[test]
fn test_render_block_data_latex_escaped() {
let block = Block::Step {
id: 1,
title: "Test".to_string(),
equations: vec!["a < b & c > d".to_string()],
notes: vec![],
is_result: false,
};
let html = render_block(&block);
assert!(html.contains("data-latex=\"a < b & c > d\""));
}
#[test]
fn test_render_empty_document() {
let doc = Document {
title: "Empty".to_string(),
theme: Theme::Dark,
blocks: vec![],
};
let html = render_blocks_html(&doc);
assert!(html.is_empty());
let full = render_full_page(&doc);
assert!(full.contains("Empty"));
assert!(full.contains("board-content"));
}
#[test]
fn test_render_equation_error_card_in_step() {
let block = Block::Step {
id: 1,
title: "Bad".to_string(),
equations: vec!["\\frac{".to_string()],
notes: vec![],
is_result: false,
};
let html = render_block(&block);
assert!(html.contains("error-card"));
assert!(html.contains("error-msg"));
}
#[test]
fn test_render_reply_content_prose_only() {
let html = render_reply_content("Just some text with $x^2$ inline", 1);
assert!(html.contains("reply-content"));
assert!(html.contains("reply-prose"));
assert!(html.contains("katex"));
assert!(!html.contains("equation-card"));
}
#[test]
fn test_render_reply_content_single_equation() {
let html = render_reply_content("Before $$E = mc^2$$ after", 3);
assert!(html.contains("reply-content"));
assert!(html.contains("reply-equation"));
assert!(html.contains("(3.1)"));
assert!(html.contains("Before"));
assert!(html.contains("after"));
}
#[test]
fn test_render_reply_content_multiple_equations() {
let html = render_reply_content("Start $$a = 1$$ middle $$b = 2$$ end", 2);
assert!(html.contains("(2.1)"));
assert!(html.contains("(2.2)"));
assert!(html.contains("Start"));
assert!(html.contains("middle"));
assert!(html.contains("end"));
}
#[test]
fn test_render_reply_content_equation_only() {
let html = render_reply_content("$$x = 1$$", 1);
assert!(html.contains("(1.1)"));
assert!(html.contains("equation-card"));
assert!(!html.contains("reply-prose"));
}
#[test]
fn test_render_reply_content_invalid_latex() {
let html = render_reply_content("Bad: $$\\frac{$$ ok", 1);
assert!(html.contains("error-card"));
assert!(html.contains("ok"));
}
#[test]
fn test_render_reply_content_empty() {
let html = render_reply_content("", 1);
assert!(html.contains("reply-content"));
assert!(!html.contains("reply-prose"));
assert!(!html.contains("equation-card"));
}
#[test]
fn test_render_reply_content_inline_math_in_prose() {
let html = render_reply_content("Where $n$ is the quantum number $$E_n = -13.6/n^2$$", 1);
assert!(html.contains("(1.1)"));
assert!(html.contains("katex"));
}
#[test]
fn test_render_reply_content_unclosed_display_math() {
let html = render_reply_content("Text $$ unclosed", 1);
assert!(html.contains("reply-prose"));
assert!(!html.contains("equation-card"));
}
}