magical markdown slides
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(¤t_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}