magical markdown slides
at f522cbe1c6de6ff5ea56d79be8bfd98676ccbee5 551 lines 18 kB view raw
1use crate::highlighter; 2use crate::slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}; 3use crate::theme::ThemeColors; 4 5/// Print slides to stdout with formatted output 6/// 7/// Renders slides as plain text with ANSI colors and width constraints. 8pub fn print_slides_to_stdout( 9 slides: &[crate::slide::Slide], theme: &ThemeColors, width: usize, 10) -> std::io::Result<()> { 11 let stdout = std::io::stdout(); 12 let mut handle = stdout.lock(); 13 print_slides(&mut handle, slides, theme, width) 14} 15 16/// Print slides to any writer with formatted output 17pub fn print_slides<W: std::io::Write>( 18 writer: &mut W, slides: &[crate::slide::Slide], theme: &ThemeColors, width: usize, 19) -> std::io::Result<()> { 20 for (idx, slide) in slides.iter().enumerate() { 21 if idx > 0 { 22 writeln!(writer)?; 23 let sep_text = "".repeat(width); 24 let separator = theme.rule(&sep_text); 25 writeln!(writer, "{separator}")?; 26 writeln!(writer)?; 27 } 28 29 print_slide(writer, slide, theme, width)?; 30 } 31 32 Ok(()) 33} 34 35/// Print a single slide with formatted blocks 36fn print_slide<W: std::io::Write>( 37 writer: &mut W, slide: &crate::slide::Slide, theme: &ThemeColors, width: usize, 38) -> std::io::Result<()> { 39 for block in &slide.blocks { 40 print_block(writer, block, theme, width, 0)?; 41 writeln!(writer)?; 42 } 43 44 Ok(()) 45} 46 47/// Print a single block with appropriate formatting 48fn print_block<W: std::io::Write>( 49 writer: &mut W, block: &Block, theme: &ThemeColors, width: usize, indent: usize, 50) -> std::io::Result<()> { 51 match block { 52 Block::Heading { level, spans } => { 53 print_heading(writer, *level, spans, theme)?; 54 } 55 Block::Paragraph { spans } => { 56 print_paragraph(writer, spans, theme, width, indent)?; 57 } 58 Block::Code(code) => { 59 print_code_block(writer, code, theme, width)?; 60 } 61 Block::List(list) => { 62 print_list(writer, list, theme, width, indent)?; 63 } 64 Block::Rule => { 65 let rule_text = "".repeat(width.saturating_sub(indent)); 66 let rule = theme.rule(&rule_text); 67 writeln!(writer, "{}{}", " ".repeat(indent), rule)?; 68 } 69 Block::BlockQuote { blocks } => { 70 print_blockquote(writer, blocks, theme, width, indent)?; 71 } 72 Block::Table(table) => { 73 print_table(writer, table, theme, width)?; 74 } 75 } 76 77 Ok(()) 78} 79 80/// Print a heading with level-appropriate styling using Unicode block symbols 81fn print_heading<W: std::io::Write>( 82 writer: &mut W, level: u8, spans: &[TextSpan], theme: &ThemeColors, 83) -> std::io::Result<()> { 84 let prefix = match level { 85 1 => "", 86 2 => "", 87 3 => "", 88 4 => "", 89 _ => "", 90 }; 91 92 write!(writer, "{}", theme.heading(&prefix))?; 93 94 for span in spans { 95 print_span(writer, span, theme, true)?; 96 } 97 98 writeln!(writer)?; 99 Ok(()) 100} 101 102/// Print a paragraph with word wrapping 103fn print_paragraph<W: std::io::Write>( 104 writer: &mut W, spans: &[TextSpan], theme: &ThemeColors, width: usize, indent: usize, 105) -> std::io::Result<()> { 106 let indent_str = " ".repeat(indent); 107 let effective_width = width.saturating_sub(indent); 108 109 let text = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(""); 110 111 let words: Vec<&str> = text.split_whitespace().collect(); 112 let mut current_line = String::new(); 113 114 for word in words { 115 if current_line.is_empty() { 116 current_line = word.to_string(); 117 } else if current_line.len() + 1 + word.len() <= effective_width { 118 current_line.push(' '); 119 current_line.push_str(word); 120 } else { 121 write!(writer, "{indent_str}")?; 122 for span in spans { 123 if current_line.contains(&span.text) { 124 print_span(writer, span, theme, false)?; 125 break; 126 } 127 } 128 if !spans.is_empty() && !current_line.is_empty() { 129 write!(writer, "{}", theme.body(&current_line))?; 130 } 131 writeln!(writer)?; 132 current_line = word.to_string(); 133 } 134 } 135 136 if !current_line.is_empty() { 137 write!(writer, "{indent_str}")?; 138 for span in spans { 139 print_span(writer, span, theme, false)?; 140 } 141 writeln!(writer)?; 142 } 143 144 Ok(()) 145} 146 147/// Print a code block with syntax highlighting 148fn print_code_block<W: std::io::Write>( 149 writer: &mut W, code: &CodeBlock, theme: &ThemeColors, width: usize, 150) -> std::io::Result<()> { 151 if let Some(lang) = &code.language { 152 writeln!(writer, "{}", theme.code_fence(&format!("```{lang}")))?; 153 } else { 154 writeln!(writer, "{}", theme.code_fence(&"```"))?; 155 } 156 157 let highlighted_lines = highlighter::highlight_code(&code.code, code.language.as_deref(), theme); 158 159 for tokens in highlighted_lines { 160 let mut line_length = 0; 161 for token in tokens { 162 if line_length + token.text.len() > width - 4 { 163 let remaining = (width - 4).saturating_sub(line_length); 164 if remaining > 0 { 165 let trimmed = &token.text[..remaining.min(token.text.len())]; 166 write!(writer, "{}", token.color.to_owo_color(&trimmed))?; 167 } 168 break; 169 } 170 write!(writer, "{}", token.color.to_owo_color(&token.text))?; 171 line_length += token.text.len(); 172 } 173 writeln!(writer)?; 174 } 175 176 writeln!(writer, "{}", theme.code_fence(&"```"))?; 177 Ok(()) 178} 179 180/// Print a list with bullets or numbers 181fn print_list<W: std::io::Write>( 182 writer: &mut W, list: &List, theme: &ThemeColors, _width: usize, indent: usize, 183) -> std::io::Result<()> { 184 for (idx, item) in list.items.iter().enumerate() { 185 let marker = if list.ordered { format!("{}. ", idx + 1) } else { "".to_string() }; 186 187 write!(writer, "{}", " ".repeat(indent))?; 188 write!(writer, "{}", theme.list_marker(&marker))?; 189 190 for span in &item.spans { 191 print_span(writer, span, theme, false)?; 192 } 193 194 writeln!(writer)?; 195 196 if let Some(nested) = &item.nested { 197 print_list(writer, nested, theme, _width, indent + 2)?; 198 } 199 } 200 201 Ok(()) 202} 203 204/// Print a blockquote with border 205fn print_blockquote<W: std::io::Write>( 206 writer: &mut W, blocks: &[Block], theme: &ThemeColors, width: usize, indent: usize, 207) -> std::io::Result<()> { 208 for block in blocks { 209 match block { 210 Block::Paragraph { spans } => { 211 write!(writer, "{}", " ".repeat(indent))?; 212 write!(writer, "{}", theme.blockquote_border(&""))?; 213 for span in spans { 214 print_span(writer, span, theme, false)?; 215 } 216 writeln!(writer)?; 217 } 218 _ => { 219 write!(writer, "{}", " ".repeat(indent))?; 220 write!(writer, "{}", theme.blockquote_border(&""))?; 221 print_block(writer, block, theme, width, indent + 2)?; 222 } 223 } 224 } 225 226 Ok(()) 227} 228 229/// Print a table with borders and proper column width calculation 230/// 231/// Calculates column widths based on content and distributes available space 232fn print_table<W: std::io::Write>( 233 writer: &mut W, table: &Table, theme: &ThemeColors, width: usize, 234) -> std::io::Result<()> { 235 let col_count = table.headers.len(); 236 if col_count == 0 { 237 return Ok(()); 238 } 239 240 let col_widths = calculate_column_widths(table, width); 241 242 if !table.headers.is_empty() { 243 print_table_row(writer, &table.headers, &col_widths, theme, true)?; 244 245 let separator = build_table_separator(&col_widths); 246 writeln!(writer, "{}", theme.table_border(&separator))?; 247 } 248 249 for row in &table.rows { 250 print_table_row(writer, row, &col_widths, theme, false)?; 251 } 252 253 Ok(()) 254} 255 256/// Calculate column widths based on content and available space 257fn calculate_column_widths(table: &Table, max_width: usize) -> Vec<usize> { 258 let col_count = table.headers.len(); 259 if col_count == 0 { 260 return vec![]; 261 } 262 263 let mut col_widths = vec![0; col_count]; 264 265 for (col_idx, header) in table.headers.iter().enumerate() { 266 let content_len: usize = header.iter().map(|s| s.text.len()).sum(); 267 col_widths[col_idx] = content_len.max(3); 268 } 269 270 for row in &table.rows { 271 for (col_idx, cell) in row.iter().enumerate() { 272 if col_idx < col_widths.len() { 273 let content_len = cell.iter().map(|s| s.text.len()).sum(); 274 col_widths[col_idx] = col_widths[col_idx].max(content_len); 275 } 276 } 277 } 278 279 let separator_width = (col_count - 1) * 3; 280 let padding_width = col_count * 2; 281 let available_width = max_width.saturating_sub(separator_width + padding_width); 282 283 let total_content_width: usize = col_widths.iter().sum(); 284 285 if total_content_width > available_width { 286 let scale_factor = available_width as f64 / total_content_width as f64; 287 for width in &mut col_widths { 288 *width = ((*width as f64 * scale_factor).ceil() as usize).max(3); 289 } 290 } 291 292 col_widths 293} 294 295/// Build a table separator line with proper column separators 296fn build_table_separator(col_widths: &[usize]) -> String { 297 let mut separator = String::new(); 298 for (idx, &width) in col_widths.iter().enumerate() { 299 if idx > 0 { 300 separator.push_str("─┼─"); 301 } 302 separator.push_str(&"".repeat(width + 2)); 303 } 304 separator 305} 306 307/// Print a single table row with proper padding and alignment 308fn print_table_row<W: std::io::Write>( 309 writer: &mut W, cells: &[Vec<TextSpan>], col_widths: &[usize], theme: &ThemeColors, is_header: bool, 310) -> std::io::Result<()> { 311 for (idx, cell) in cells.iter().enumerate() { 312 if idx > 0 { 313 write!(writer, "{}", theme.table_border(&""))?; 314 } else { 315 write!(writer, " ")?; 316 } 317 318 let col_width = col_widths.get(idx).copied().unwrap_or(10); 319 let content: String = cell.iter().map(|s| s.text.as_str()).collect(); 320 let content_len = content.len(); 321 322 for span in cell { 323 print_span(writer, span, theme, is_header)?; 324 } 325 326 if content_len < col_width { 327 write!(writer, "{}", " ".repeat(col_width - content_len))?; 328 } 329 330 write!(writer, " ")?; 331 } 332 writeln!(writer)?; 333 334 Ok(()) 335} 336 337/// Print a text span with styling 338fn print_span<W: std::io::Write>( 339 writer: &mut W, span: &TextSpan, theme: &ThemeColors, is_heading: bool, 340) -> std::io::Result<()> { 341 let text = &span.text; 342 let style = &span.style; 343 344 if is_heading { 345 write!(writer, "{}", apply_text_style(&theme.heading(text), style))?; 346 } else if style.code { 347 write!(writer, "{}", apply_text_style(&theme.code(text), style))?; 348 } else { 349 write!(writer, "{}", apply_text_style(&theme.body(text), style))?; 350 } 351 352 Ok(()) 353} 354 355/// Apply text style modifiers to styled text 356fn apply_text_style<T: std::fmt::Display>(styled: &owo_colors::Styled<T>, text_style: &TextStyle) -> String { 357 let mut result = styled.to_string(); 358 359 if text_style.bold { 360 result = format!("\x1b[1m{result}\x1b[22m"); 361 } 362 if text_style.italic { 363 result = format!("\x1b[3m{result}\x1b[23m"); 364 } 365 if text_style.strikethrough { 366 result = format!("\x1b[9m{result}\x1b[29m"); 367 } 368 369 result 370} 371 372#[cfg(test)] 373mod tests { 374 use super::*; 375 use crate::slide::Slide; 376 use crate::slide::{Alignment, Table}; 377 378 #[test] 379 fn print_empty_slides() { 380 let slides: Vec<Slide> = vec![]; 381 let theme = ThemeColors::default(); 382 let mut output = Vec::new(); 383 384 let result = print_slides(&mut output, &slides, &theme, 80); 385 assert!(result.is_ok()); 386 assert_eq!(output.len(), 0); 387 } 388 389 #[test] 390 fn print_single_heading() { 391 let slide = Slide::with_blocks(vec![Block::Heading { 392 level: 1, 393 spans: vec![TextSpan::plain("Hello World")], 394 }]); 395 let theme = ThemeColors::default(); 396 let mut output = Vec::new(); 397 398 let result = print_slides(&mut output, &[slide], &theme, 80); 399 assert!(result.is_ok()); 400 let text = String::from_utf8_lossy(&output); 401 assert!(text.contains("Hello World")); 402 } 403 404 #[test] 405 fn print_paragraph_with_wrapping() { 406 let long_text = "This is a very long paragraph that should wrap when printed to stdout with a width constraint applied to ensure readability."; 407 let slide = Slide::with_blocks(vec![Block::Paragraph { spans: vec![TextSpan::plain(long_text)] }]); 408 let theme = ThemeColors::default(); 409 let mut output = Vec::new(); 410 411 let result = print_slides(&mut output, &[slide], &theme, 40); 412 assert!(result.is_ok()); 413 } 414 415 #[test] 416 fn print_code_block() { 417 let slide = Slide::with_blocks(vec![Block::Code(CodeBlock::with_language( 418 "rust", 419 "fn main() {\n println!(\"Hello\");\n}", 420 ))]); 421 let theme = ThemeColors::default(); 422 let mut output = Vec::new(); 423 424 let result = print_slides(&mut output, &[slide], &theme, 80); 425 assert!(result.is_ok()); 426 427 let text = String::from_utf8_lossy(&output); 428 assert!(text.contains("```rust")); 429 assert!(text.contains("fn") && text.contains("main")); 430 assert!(text.contains("println")); 431 } 432 433 #[test] 434 fn print_multiple_slides() { 435 let slides = vec![ 436 Slide::with_blocks(vec![Block::Heading { 437 level: 1, 438 spans: vec![TextSpan::plain("Slide 1")], 439 }]), 440 Slide::with_blocks(vec![Block::Heading { 441 level: 1, 442 spans: vec![TextSpan::plain("Slide 2")], 443 }]), 444 ]; 445 446 let theme = ThemeColors::default(); 447 let mut output = Vec::new(); 448 let result = print_slides(&mut output, &slides, &theme, 80); 449 assert!(result.is_ok()); 450 451 let text = String::from_utf8_lossy(&output); 452 assert!(text.contains("Slide 1")); 453 assert!(text.contains("Slide 2")); 454 } 455 456 #[test] 457 fn print_table_with_headers() { 458 let table = Table { 459 headers: vec![ 460 vec![TextSpan::plain("Name")], 461 vec![TextSpan::plain("Age")], 462 vec![TextSpan::plain("City")], 463 ], 464 rows: vec![ 465 vec![ 466 vec![TextSpan::plain("Alice")], 467 vec![TextSpan::plain("30")], 468 vec![TextSpan::plain("NYC")], 469 ], 470 vec![ 471 vec![TextSpan::plain("Bob")], 472 vec![TextSpan::plain("25")], 473 vec![TextSpan::plain("LA")], 474 ], 475 ], 476 alignments: vec![Alignment::Left, Alignment::Left, Alignment::Left], 477 }; 478 479 let slide = Slide::with_blocks(vec![Block::Table(table)]); 480 let theme = ThemeColors::default(); 481 let mut output = Vec::new(); 482 483 let result = print_slides(&mut output, &[slide], &theme, 80); 484 assert!(result.is_ok()); 485 486 let text = String::from_utf8_lossy(&output); 487 assert!(text.contains("Name")); 488 assert!(text.contains("Age")); 489 assert!(text.contains("City")); 490 assert!(text.contains("Alice")); 491 assert!(text.contains("Bob")); 492 assert!(text.contains("")); 493 assert!(text.contains("")); 494 } 495 496 #[test] 497 fn print_table_with_column_width_calculation() { 498 let table = Table { 499 headers: vec![vec![TextSpan::plain("Short")], vec![TextSpan::plain("Long Header")]], 500 rows: vec![ 501 vec![vec![TextSpan::plain("A")], vec![TextSpan::plain("B")]], 502 vec![vec![TextSpan::plain("Very Long Content")], vec![TextSpan::plain("X")]], 503 ], 504 alignments: vec![Alignment::Left, Alignment::Left], 505 }; 506 507 let col_widths = calculate_column_widths(&table, 80); 508 509 assert_eq!(col_widths.len(), 2); 510 assert!(col_widths[0] >= 17); 511 assert!(col_widths[1] >= 11); 512 } 513 514 #[test] 515 fn print_table_empty_headers() { 516 let table = Table { headers: vec![], rows: vec![], alignments: vec![] }; 517 518 let slide = Slide::with_blocks(vec![Block::Table(table)]); 519 let theme = ThemeColors::default(); 520 let mut output = Vec::new(); 521 522 let result = print_slides(&mut output, &[slide], &theme, 80); 523 assert!(result.is_ok()); 524 } 525 526 #[test] 527 fn calculate_column_widths_scales_to_fit() { 528 let table = Table { 529 headers: vec![ 530 vec![TextSpan::plain("A".repeat(50))], 531 vec![TextSpan::plain("B".repeat(50))], 532 ], 533 rows: vec![], 534 alignments: vec![Alignment::Left, Alignment::Left], 535 }; 536 537 let col_widths = calculate_column_widths(&table, 40); 538 let total_width: usize = col_widths.iter().sum(); 539 540 assert!(total_width <= 40); 541 } 542 543 #[test] 544 fn build_table_separator_correct_format() { 545 let col_widths = vec![5, 10, 7]; 546 let separator = build_table_separator(&col_widths); 547 548 assert!(separator.contains("─┼─")); 549 assert!(separator.contains("")); 550 } 551}