use anyhow::Result;
use crossterm::style::{
Attribute, Color as CrosstermColor, ResetColor, SetAttribute, SetForegroundColor,
};
use std::fmt::Write;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{document::*, ColorDepth};
pub struct AnsiOptions {
pub terminal_width: usize,
pub color_depth: ColorDepth,
}
impl Default for AnsiOptions {
fn default() -> Self {
Self {
terminal_width: std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(80),
color_depth: ColorDepth::Auto,
}
}
}
pub fn export_to_ansi_with_options(document: &Document, options: &AnsiOptions) -> Result<String> {
let mut output = String::new();
write_ansi_heading(&mut output, &document.title, 1, options)?;
output.push('\n');
writeln!(
output,
"{}Document Information{}",
format_ansi_text("", true, false, false, false, None, options),
format_ansi_reset()
)?;
let prefix = "- File: ";
let available = options.terminal_width.saturating_sub(prefix.len());
let path = &document.metadata.file_path;
let file_str = if UnicodeWidthStr::width(path.as_str()) <= available {
path.clone()
} else {
let truncated: String = path
.graphemes(true)
.rev()
.scan(0usize, |w, g| {
*w += UnicodeWidthStr::width(g);
if *w < available {
Some(g)
} else {
None
}
})
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{truncated}")
};
writeln!(output, "{prefix}{file_str}")?;
writeln!(output, "- Pages: {}", document.metadata.page_count)?;
writeln!(output, "- Words: {}", document.metadata.word_count)?;
if let Some(author) = &document.metadata.author {
writeln!(output, "- Author: {author}")?;
}
output.push('\n');
let separator = "=".repeat(std::cmp::min(50, options.terminal_width));
writeln!(output, "{separator}")?;
output.push('\n');
for element in &document.elements {
match element {
DocumentElement::Heading {
level,
text,
number,
} => {
let heading_text = if let Some(number) = number {
format!("{number} {text}")
} else {
text.clone()
};
write_ansi_heading(&mut output, &heading_text, *level, options)?;
output.push('\n');
}
DocumentElement::Paragraph { runs } => {
if runs.is_empty() || runs.iter().all(|run| run.text.trim().is_empty()) {
continue;
}
write_ansi_paragraph(&mut output, runs, options)?;
output.push('\n');
}
DocumentElement::List { items, ordered } => {
write_ansi_list(&mut output, items, *ordered, options)?;
output.push('\n');
}
DocumentElement::Table { table } => {
write_ansi_table(&mut output, table, options)?;
output.push('\n');
}
DocumentElement::Image { description, .. } => {
writeln!(
output,
"{}🖼️ [Image: {}]{}",
format_ansi_color(Some("#FF00FF"), options), description,
format_ansi_reset()
)?;
output.push('\n');
}
DocumentElement::Equation { latex, .. } => {
writeln!(
output,
"{}📐 {}{}",
format_ansi_color(Some("#00AAFF"), options), latex,
format_ansi_reset()
)?;
output.push('\n');
}
DocumentElement::CodeBlock { text } => {
let code_color = format_ansi_color(Some("#AAFFAA"), options);
let reset = format_ansi_reset();
for line in text.lines() {
writeln!(output, " {code_color}{line}{reset}")?;
}
output.push('\n');
}
DocumentElement::TextBox { lines } => {
let border_color = format_ansi_color(Some("#00FFFF"), options);
let reset = format_ansi_reset();
let inner_width = options.terminal_width.saturating_sub(4);
let bar = "─".repeat(options.terminal_width.saturating_sub(2));
writeln!(output, "{border_color}┌{bar}┐{reset}")?;
for line in lines {
let truncated: String = line.chars().take(inner_width).collect();
writeln!(
output,
"{border_color}│{reset} {truncated:<inner_width$} {border_color}│{reset}",
inner_width = inner_width
)?;
}
writeln!(output, "{border_color}└{bar}┘{reset}")?;
output.push('\n');
}
DocumentElement::PageBreak => {
let separator = "─".repeat(std::cmp::min(60, options.terminal_width));
writeln!(
output,
"{}{}{}",
format_ansi_color(Some("#666666"), options), separator,
format_ansi_reset()
)?;
output.push('\n');
}
}
}
Ok(output)
}
fn write_ansi_heading(
output: &mut String,
text: &str,
level: u8,
options: &AnsiOptions,
) -> Result<()> {
let color = match level {
1 => Some("#FFFF00"), 2 => Some("#00FF00"), _ => Some("#00FFFF"), };
let prefix = match level {
1 => "■ ",
2 => " ▶ ",
3 => " ◦ ",
_ => " • ",
};
let prefix_width = UnicodeWidthStr::width(prefix);
let available_width = options.terminal_width.saturating_sub(prefix_width);
let wrapped = wrap_plain_text(text, available_width);
let indent = " ".repeat(prefix_width);
for (i, line) in wrapped.iter().enumerate() {
let display = if i == 0 {
format!("{prefix}{line}")
} else {
format!("{indent}{line}")
};
writeln!(
output,
"{}",
format_ansi_text(&display, true, false, false, false, color, options)
)?;
}
Ok(())
}
fn wrap_plain_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = UnicodeWidthStr::width(word);
if current_width == 0 {
current_line.push_str(word);
current_width = word_width;
} else if current_width + 1 + word_width > max_width {
lines.push(current_line.clone());
current_line = word.to_string();
current_width = word_width;
} else {
current_line.push(' ');
current_line.push_str(word);
current_width += 1 + word_width;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn write_ansi_paragraph(
output: &mut String,
runs: &[FormattedRun],
options: &AnsiOptions,
) -> Result<()> {
let wrapped_lines = wrap_formatted_runs(runs, options);
for line in wrapped_lines {
writeln!(output, "{}{}", line, format_ansi_reset())?;
}
Ok(())
}
fn wrap_formatted_runs(runs: &[FormattedRun], options: &AnsiOptions) -> Vec<String> {
if runs.is_empty() {
return vec![];
}
let max_width = options.terminal_width;
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut line_needs_formatting = false;
for run in runs {
let graphemes: Vec<&str> = run.text.graphemes(true).collect();
let mut word = String::new();
let mut word_width = 0;
let format_start = get_ansi_format_start(
run.formatting.bold,
run.formatting.italic,
run.formatting.underline,
run.formatting.strikethrough,
run.formatting.color.as_deref(),
options,
);
for grapheme in graphemes {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if grapheme == " " || grapheme == "\n" {
if !word.is_empty() {
if current_width + word_width > max_width && current_width > 0 {
if line_needs_formatting {
current_line.push_str(&format_ansi_reset());
}
lines.push(current_line.clone());
current_line.clear();
current_width = 0;
line_needs_formatting = false;
}
if !line_needs_formatting && !format_start.is_empty() {
current_line.push_str(&format_start);
line_needs_formatting = true;
}
current_line.push_str(&word);
current_width += word_width;
word.clear();
word_width = 0;
}
if grapheme == "\n" {
if line_needs_formatting {
current_line.push_str(&format_ansi_reset());
}
lines.push(current_line.clone());
current_line.clear();
current_width = 0;
line_needs_formatting = false;
} else if current_width < max_width {
current_line.push(' ');
current_width += 1;
}
} else {
word.push_str(grapheme);
word_width += grapheme_width;
}
}
if !word.is_empty() {
if current_width + word_width > max_width && current_width > 0 {
if line_needs_formatting {
current_line.push_str(&format_ansi_reset());
}
lines.push(current_line.clone());
current_line.clear();
current_width = 0;
line_needs_formatting = false;
}
if !line_needs_formatting && !format_start.is_empty() {
current_line.push_str(&format_start);
line_needs_formatting = true;
}
current_line.push_str(&word);
current_width += word_width;
}
if line_needs_formatting && !current_line.is_empty() {
current_line.push_str(&format_ansi_reset());
line_needs_formatting = false;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
lines
}
fn get_ansi_format_start(
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
color: Option<&str>,
options: &AnsiOptions,
) -> String {
let mut result = String::new();
if bold {
result.push_str(&format!("{}", SetAttribute(Attribute::Bold)));
}
if italic {
result.push_str(&format!("{}", SetAttribute(Attribute::Italic)));
}
if underline {
result.push_str(&format!("{}", SetAttribute(Attribute::Underlined)));
}
if strikethrough {
result.push_str(&format!("{}", SetAttribute(Attribute::CrossedOut)));
}
if let Some(color_hex) = color {
result.push_str(&format_ansi_color(Some(color_hex), options));
}
result
}
fn write_ansi_list(
output: &mut String,
items: &[ListItem],
ordered: bool,
options: &AnsiOptions,
) -> Result<()> {
for (i, item) in items.iter().enumerate() {
let bullet = if ordered {
format!("{}. ", i + 1)
} else {
"• ".to_string()
};
let indent = " ".repeat(item.level as usize);
let bullet_color = format_ansi_color(Some("#0066FF"), options); let prefix = format!("{}{}{}", bullet_color, indent, bullet);
let prefix_visual_width = indent.len() + bullet.len();
let available_width = options.terminal_width.saturating_sub(prefix_visual_width);
let wrapped_lines = wrap_formatted_runs_with_width(&item.runs, available_width, options);
for (line_idx, line) in wrapped_lines.iter().enumerate() {
if line_idx == 0 {
writeln!(output, "{}{}{}", prefix, format_ansi_reset(), line)?;
} else {
writeln!(output, "{}{}", " ".repeat(prefix_visual_width), line)?;
}
}
}
Ok(())
}
fn wrap_formatted_runs_with_width(
runs: &[FormattedRun],
max_width: usize,
options: &AnsiOptions,
) -> Vec<String> {
if runs.is_empty() || max_width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut line_needs_formatting = false;
for run in runs {
let graphemes: Vec<&str> = run.text.graphemes(true).collect();
let mut word = String::new();
let mut word_width = 0;
let format_start = get_ansi_format_start(
run.formatting.bold,
run.formatting.italic,
run.formatting.underline,
run.formatting.strikethrough,
run.formatting.color.as_deref(),
options,
);
for grapheme in graphemes {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if grapheme == " " || grapheme == "\n" {
if !word.is_empty() {
if current_width + word_width > max_width && current_width > 0 {
if line_needs_formatting {
current_line.push_str(&format_ansi_reset());
}
lines.push(current_line.clone());
current_line.clear();
current_width = 0;
line_needs_formatting = false;
}
if !line_needs_formatting && !format_start.is_empty() {
current_line.push_str(&format_start);
line_needs_formatting = true;
}
current_line.push_str(&word);
current_width += word_width;
word.clear();
word_width = 0;
}
if grapheme == "\n" {
if line_needs_formatting {
current_line.push_str(&format_ansi_reset());
}
lines.push(current_line.clone());
current_line.clear();
current_width = 0;
line_needs_formatting = false;
} else if current_width < max_width {
current_line.push(' ');
current_width += 1;
}
} else {
word.push_str(grapheme);
word_width += grapheme_width;
}
}
if !word.is_empty() {
if current_width + word_width > max_width && current_width > 0 {
if line_needs_formatting {
current_line.push_str(&format_ansi_reset());
}
lines.push(current_line.clone());
current_line.clear();
current_width = 0;
line_needs_formatting = false;
}
if !line_needs_formatting && !format_start.is_empty() {
current_line.push_str(&format_start);
line_needs_formatting = true;
}
current_line.push_str(&word);
current_width += word_width;
}
if line_needs_formatting && !current_line.is_empty() {
current_line.push_str(&format_ansi_reset());
line_needs_formatting = false;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn write_ansi_table(output: &mut String, table: &TableData, options: &AnsiOptions) -> Result<()> {
if let Some(title) = &table.metadata.title {
let formatted_title = format_ansi_text(
&format!("📊 {title}"),
true,
false,
false,
false,
Some("#0066FF"), options,
);
writeln!(output, "{}{}", formatted_title, format_ansi_reset())?;
output.push('\n');
}
if !table.headers.is_empty() {
write!(output, "│")?;
for header in &table.headers {
write!(
output,
" {}{}{} │",
format_ansi_text("", true, false, false, false, None, options),
header.content,
format_ansi_reset()
)?;
}
writeln!(output)?;
write!(output, "├")?;
for _ in &table.headers {
write!(output, "─────┼")?;
}
writeln!(output, "┤")?;
for row in &table.rows {
write!(output, "│")?;
for cell in row {
write!(output, " {} │", cell.content)?;
}
writeln!(output)?;
}
}
Ok(())
}
fn format_ansi_text(
text: &str,
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
color: Option<&str>,
options: &AnsiOptions,
) -> String {
let mut result = String::new();
if bold {
result.push_str(&format!("{}", SetAttribute(Attribute::Bold)));
}
if italic {
result.push_str(&format!("{}", SetAttribute(Attribute::Italic)));
}
if underline {
result.push_str(&format!("{}", SetAttribute(Attribute::Underlined)));
}
if strikethrough {
result.push_str(&format!("{}", SetAttribute(Attribute::CrossedOut)));
}
if let Some(color_hex) = color {
result.push_str(&format_ansi_color(Some(color_hex), options));
}
result.push_str(text);
result.push_str(&format_ansi_reset());
result
}
fn format_ansi_color(color_hex: Option<&str>, options: &AnsiOptions) -> String {
let Some(hex) = color_hex else {
return String::new();
};
match convert_hex_to_crossterm_color(hex, &options.color_depth) {
Some(color) => format!("{}", SetForegroundColor(color)),
None => String::new(),
}
}
fn format_ansi_reset() -> String {
format!("{ResetColor}")
}
fn convert_hex_to_crossterm_color(hex: &str, color_depth: &ColorDepth) -> Option<CrosstermColor> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
match color_depth {
ColorDepth::Monochrome => None,
ColorDepth::Standard => {
let color_index = rgb_to_ansi_16(r, g, b);
Some(CrosstermColor::AnsiValue(color_index))
}
ColorDepth::Extended => {
let color_index = rgb_to_ansi_256(r, g, b);
Some(CrosstermColor::AnsiValue(color_index))
}
ColorDepth::TrueColor | ColorDepth::Auto => {
Some(CrosstermColor::Rgb { r, g, b })
}
}
}
fn rgb_to_ansi_16(r: u8, g: u8, b: u8) -> u8 {
let r_bright = r > 127;
let g_bright = g > 127;
let b_bright = b > 127;
let base = match (r > 64, g > 64, b > 64) {
(false, false, false) => 0, (false, false, true) => 4, (false, true, false) => 2, (false, true, true) => 6, (true, false, false) => 1, (true, false, true) => 5, (true, true, false) => 3, (true, true, true) => 7, };
if r_bright || g_bright || b_bright {
base + 8
} else {
base
}
}
fn rgb_to_ansi_256(r: u8, g: u8, b: u8) -> u8 {
if r == g && g == b {
if r < 8 {
16
} else if r > 247 {
231
} else {
232 + (r - 8) / 10
}
} else {
let r_index = (r as f32 / 255.0 * 5.0) as u8;
let g_index = (g as f32 / 255.0 * 5.0) as u8;
let b_index = (b as f32 / 255.0 * 5.0) as u8;
16 + 36 * r_index + 6 * g_index + b_index
}
}