Skip to main content

mq_view/
renderer.rs

1use crate::highlighter::SyntaxHighlighter;
2use colored::*;
3use mq_markdown::{Markdown, Node};
4use std::io::{self, Write};
5use std::path::Path;
6use std::sync::LazyLock;
7use terminal_size::{Height, Width, terminal_size};
8
9/// Configuration for rendering markdown
10#[derive(Debug, Clone)]
11pub struct RenderConfig {
12    /// Enable full-width background highlighting for headers
13    pub header_full_width_highlight: bool,
14}
15
16impl Default for RenderConfig {
17    fn default() -> Self {
18        Self {
19            header_full_width_highlight: true,
20        }
21    }
22}
23
24/// Unicode header symbols (①②③④⑤⑥)
25const HEADER_SYMBOLS: &[&str] = &["①", "②", "③", "④", "⑤", "⑥"];
26
27/// Unicode bullet symbols for lists
28const LIST_BULLETS: &[&str] = &["●", "○", "◆", "◇"];
29
30static WIDTH: LazyLock<usize> = LazyLock::new(|| {
31    let size = terminal_size();
32    if let Some((Width(w), Height(_))) = size {
33        w.into()
34    } else {
35        80
36    }
37});
38
39/// GitHub-style callout definitions
40#[derive(Debug, Clone)]
41struct Callout {
42    icon: &'static str,
43    color: colored::Color,
44    name: &'static str,
45}
46
47const CALLOUTS: &[(&str, Callout)] = &[
48    (
49        "NOTE",
50        Callout {
51            icon: "ℹ️",
52            color: colored::Color::Blue,
53            name: "Note",
54        },
55    ),
56    (
57        "TIP",
58        Callout {
59            icon: "💡",
60            color: colored::Color::Green,
61            name: "Tip",
62        },
63    ),
64    (
65        "IMPORTANT",
66        Callout {
67            icon: "❗",
68            color: colored::Color::Magenta,
69            name: "Important",
70        },
71    ),
72    (
73        "WARNING",
74        Callout {
75            icon: "⚠️",
76            color: colored::Color::Yellow,
77            name: "Warning",
78        },
79    ),
80    (
81        "CAUTION",
82        Callout {
83            icon: "🔥",
84            color: colored::Color::Red,
85            name: "Caution",
86        },
87    ),
88];
89
90/// Create a clickable link using ANSI escape sequences (OSC 8)
91/// Format: ESC ] 8 ; params ; URI ST display_text ESC ] 8 ; ; ST
92fn make_clickable_link(url: &str, display_text: &str) -> String {
93    // Using ST (String Terminator) \x1b\\ instead of BEL \x07 for better compatibility
94    format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, display_text)
95}
96
97/// Render a Markdown document to a writer with syntax highlighting and rich text formatting.
98///
99/// # Errors
100///
101/// Returns an `io::Error` if writing to the output fails.
102///
103/// # Examples
104///
105/// ```rust
106/// use mq_view::render_markdown;
107/// use mq_markdown::Markdown;
108/// use std::io::BufWriter;
109///
110/// let markdown: Markdown = "# Hello\n\nWorld".parse().unwrap();
111/// let mut output = Vec::new();
112/// {
113///     let mut writer = BufWriter::new(&mut output);
114///     render_markdown(&markdown, &mut writer).unwrap();
115/// }
116/// ```
117pub fn render_markdown<W: Write>(markdown: &Markdown, writer: &mut W) -> io::Result<()> {
118    render_markdown_with_config(markdown, writer, &RenderConfig::default())
119}
120
121/// Render a Markdown document to a writer with custom configuration.
122///
123/// # Errors
124///
125/// Returns an `io::Error` if writing to the output fails.
126pub fn render_markdown_with_config<W: Write>(
127    markdown: &Markdown,
128    writer: &mut W,
129    config: &RenderConfig,
130) -> io::Result<()> {
131    let mut highlighter = SyntaxHighlighter::new();
132    let mut i = 0;
133    let len = markdown.nodes.len();
134
135    while i < len {
136        let node = &markdown.nodes[i];
137        if matches!(node, Node::TableCell(_)) {
138            // Collect consecutive table-related nodes
139            let table_nodes: Vec<&Node> = markdown.nodes[i..]
140                .iter()
141                .take_while(|n| {
142                    matches!(
143                        n,
144                        Node::TableCell(_) | Node::TableAlign(_) | Node::TableRow(_)
145                    )
146                })
147                .collect();
148            render_table(&table_nodes, &mut highlighter, writer)?;
149            i += table_nodes.len();
150        } else {
151            render_node(node, 0, &mut highlighter, config, writer)?;
152            i += 1;
153        }
154    }
155    Ok(())
156}
157
158/// Render a Markdown document to a String with syntax highlighting and rich text formatting.
159///
160/// # Examples
161///
162/// ```rust
163/// use mq_view::render_markdown_to_string;
164/// use mq_markdown::Markdown;
165///
166/// let markdown: Markdown = "# Hello\n\nWorld".parse().unwrap();
167/// let rendered = render_markdown_to_string(&markdown).unwrap();
168/// println!("{}", rendered);
169/// ```
170pub fn render_markdown_to_string(markdown: &Markdown) -> io::Result<String> {
171    let mut output = Vec::new();
172    render_markdown(markdown, &mut output)?;
173    String::from_utf8(output).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
174}
175
176fn detect_callout(text: &str) -> Option<&'static Callout> {
177    let trimmed = text.trim();
178    if trimmed.starts_with("[!")
179        && trimmed.contains(']')
180        && let Some(end) = trimmed.find(']')
181    {
182        let callout_type = &trimmed[2..end];
183        return CALLOUTS
184            .iter()
185            .find(|(name, _)| name.eq_ignore_ascii_case(callout_type))
186            .map(|(_, callout)| callout);
187    }
188    None
189}
190
191fn render_node<W: Write>(
192    node: &Node,
193    depth: usize,
194    highlighter: &mut SyntaxHighlighter,
195    config: &RenderConfig,
196    writer: &mut W,
197) -> io::Result<()> {
198    render_node_inline(node, depth, false, highlighter, config, writer)
199}
200
201fn render_node_inline<W: Write>(
202    node: &Node,
203    depth: usize,
204    inline: bool,
205    highlighter: &mut SyntaxHighlighter,
206    config: &RenderConfig,
207    writer: &mut W,
208) -> io::Result<()> {
209    match node {
210        Node::Heading(heading) => {
211            if !inline {
212                writeln!(writer)?;
213            }
214
215            let symbol = HEADER_SYMBOLS
216                .get((heading.depth - 1) as usize)
217                .unwrap_or(&"⑥");
218
219            let text = render_inline_content(&heading.values);
220
221            if config.header_full_width_highlight {
222                let padding = WIDTH.saturating_sub(text.chars().count() + 3);
223                let line = format!("{}{}", text, " ".repeat(padding));
224
225                // Full-width background highlighting
226                match heading.depth {
227                    1 => {
228                        writeln!(
229                            writer,
230                            "{}{}{}",
231                            symbol.bold().black().on_bright_blue(),
232                            "  ".on_bright_blue(),
233                            line.bold().bright_black().on_bright_blue()
234                        )?;
235                    }
236                    2 => {
237                        writeln!(
238                            writer,
239                            "{}{}{}",
240                            symbol.bold().black().on_cyan(),
241                            "  ".on_cyan(),
242                            line.bold().bright_black().on_cyan()
243                        )?;
244                    }
245                    3 => {
246                        writeln!(
247                            writer,
248                            "{}{}{}",
249                            symbol.bold().black().on_yellow(),
250                            "  ".on_yellow(),
251                            line.bold().bright_black().on_yellow()
252                        )?;
253                    }
254                    4 => {
255                        writeln!(
256                            writer,
257                            "{}{}{}",
258                            symbol.bold().black().on_green(),
259                            "  ".on_green(),
260                            line.bold().bright_black().on_green()
261                        )?;
262                    }
263                    5 => {
264                        writeln!(
265                            writer,
266                            "{}{}{}",
267                            symbol.bold().black().on_magenta(),
268                            "  ".on_magenta(),
269                            line.bold().bright_black().on_magenta()
270                        )?;
271                    }
272                    _ => {
273                        writeln!(writer, "{}  {}", symbol.bold().white(), text.bold().white())?;
274                    }
275                }
276            } else {
277                // Simple header without full-width highlighting
278                match heading.depth {
279                    1 => {
280                        writeln!(
281                            writer,
282                            "{}  {}",
283                            symbol.bold().bright_blue(),
284                            text.bold().bright_blue()
285                        )?;
286                    }
287                    2 => {
288                        writeln!(writer, "{}  {}", symbol.bold().cyan(), text.bold().cyan())?;
289                    }
290                    3 => {
291                        writeln!(
292                            writer,
293                            "{}  {}",
294                            symbol.bold().yellow(),
295                            text.bold().yellow()
296                        )?;
297                    }
298                    4 => {
299                        writeln!(writer, "{}  {}", symbol.bold().green(), text.bold().green())?;
300                    }
301                    5 => {
302                        writeln!(
303                            writer,
304                            "{}  {}",
305                            symbol.bold().magenta(),
306                            text.bold().magenta()
307                        )?;
308                    }
309                    _ => {
310                        writeln!(writer, "{}  {}", symbol.bold().white(), text.bold().white())?;
311                    }
312                }
313            }
314            writeln!(writer)?;
315        }
316
317        Node::Text(text) => {
318            if !text.value.trim().is_empty() {
319                if inline {
320                    write!(writer, "{}", text.value)?;
321                } else {
322                    writeln!(writer, "{}", text.value)?;
323                }
324            }
325        }
326
327        Node::List(list) => {
328            render_list(list, depth, highlighter, config, writer)?;
329        }
330
331        Node::Code(code) => {
332            write!(writer, "{}", "\n```".bright_black())?;
333            if let Some(lang) = &code.lang {
334                write!(writer, "{}", lang.bright_black())?;
335            }
336            writeln!(writer)?;
337
338            // Apply syntax highlighting if language is specified
339            let highlighted = highlighter.highlight(&code.value, code.lang.as_deref());
340            write!(writer, "{}", highlighted)?;
341
342            writeln!(writer)?;
343            writeln!(writer, "{}", "```".bright_black())?;
344            writeln!(writer)?;
345        }
346
347        Node::CodeInline(code) => {
348            write!(writer, "{}", format!("`{}`", code.value).bright_yellow())?;
349        }
350
351        Node::Strong(strong) => {
352            write!(writer, "{}", render_inline_content(&strong.values).bold())?;
353        }
354
355        Node::Emphasis(emphasis) => {
356            write!(
357                writer,
358                "{}",
359                render_inline_content(&emphasis.values).italic()
360            )?;
361        }
362
363        Node::Link(link) => {
364            let text = render_inline_content(&link.values);
365            let url = link.url.as_str();
366
367            if text.trim().is_empty() {
368                // If no link text, just make the URL clickable
369                write!(
370                    writer,
371                    " {} {}",
372                    "🔗".bright_blue(),
373                    make_clickable_link(url, url)
374                )?;
375            } else {
376                // Make the title clickable without showing URL
377                write!(
378                    writer,
379                    " {} {}",
380                    "🔗".bright_blue(),
381                    make_clickable_link(url, &text).underline().bright_blue()
382                )?;
383            }
384        }
385
386        Node::Image(image) => {
387            let alt = image.alt.as_str();
388            let url = image.url.as_str();
389
390            let _ = render_image_to_terminal(url);
391
392            // Always show the text description as well
393            if alt.trim().is_empty() {
394                writeln!(
395                    writer,
396                    "{} {}",
397                    "🖼️ ".bright_green(),
398                    url.underline().bright_green()
399                )?;
400            } else {
401                writeln!(
402                    writer,
403                    "{} {} ({})",
404                    "🖼️ ".bright_green(),
405                    alt.bright_green(),
406                    url.bright_black()
407                )?;
408            }
409        }
410
411        Node::HorizontalRule(_) => {
412            writeln!(writer, "{}", "─".repeat(80).bright_black())?;
413            writeln!(writer)?;
414        }
415
416        Node::Blockquote(blockquote) => {
417            if !inline {
418                writeln!(writer)?;
419            }
420
421            // Check if this is a GitHub-style callout
422            let is_callout = {
423                let mut found_callout = false;
424                // Check all nodes in blockquote for callout pattern
425                for value in &blockquote.values {
426                    match value {
427                        Node::Fragment(para) => {
428                            for child in &para.values {
429                                if let Node::Text(text) = child
430                                    && detect_callout(&text.value).is_some()
431                                {
432                                    found_callout = true;
433                                    break;
434                                }
435                            }
436                        }
437                        Node::Text(text) => {
438                            if detect_callout(&text.value).is_some() {
439                                found_callout = true;
440                                break;
441                            }
442                        }
443                        _ => {}
444                    }
445                    if found_callout {
446                        break;
447                    }
448                }
449                found_callout
450            };
451
452            if is_callout {
453                render_callout_blockquote(blockquote, depth, highlighter, config, writer)?;
454            } else {
455                render_regular_blockquote(blockquote, depth, highlighter, config, writer)?;
456            }
457
458            writeln!(writer)?;
459        }
460
461        Node::Html(html) => {
462            // Apply syntax highlighting to HTML
463            let highlighted = highlighter.highlight(&html.value, Some("html"));
464            writeln!(writer, "{}", highlighted)?;
465        }
466
467        Node::Break(_) => {
468            if inline {
469                write!(writer, " ")?;
470            } else {
471                writeln!(writer)?;
472            }
473        }
474
475        Node::Fragment(fragment) => {
476            // Render paragraph as inline content on one line
477            for child in &fragment.values {
478                render_node_inline(child, depth, true, highlighter, config, writer)?;
479            }
480            // Add newline after paragraph unless we're inline
481            if !inline {
482                writeln!(writer)?;
483            }
484        }
485
486        Node::TableAlign(_) | Node::TableRow(_) => {
487            // These should be handled by render_table in render_markdown
488            // If we encounter them here, skip them
489        }
490
491        Node::TableCell(cell) => {
492            // Individual table cells outside of tables
493            // Calculate column widths for this cell
494            let column_widths = calculate_column_widths(&[Node::TableCell(cell.clone())]);
495            render_table_cell(cell, &column_widths, highlighter, config, writer)?;
496        }
497
498        // Handle other node types recursively if they have children
499        _ => {
500            if let Some(children) = get_node_children(node) {
501                for child in children {
502                    render_node_inline(child, depth, inline, highlighter, config, writer)?;
503                }
504            }
505        }
506    }
507
508    Ok(())
509}
510
511fn render_list<W: Write>(
512    list: &mq_markdown::List,
513    depth: usize,
514    highlighter: &mut SyntaxHighlighter,
515    config: &RenderConfig,
516    writer: &mut W,
517) -> io::Result<()> {
518    let indent = "  ".repeat(depth);
519    let bullet_index = depth % LIST_BULLETS.len();
520    let bullet = if list.ordered {
521        format!("{}.", list.index + 1)
522    } else {
523        LIST_BULLETS[bullet_index].to_string()
524    };
525
526    // Handle checkbox lists
527    let checkbox = match list.checked {
528        Some(true) => "☑️ ",
529        Some(false) => "☐ ",
530        None => "",
531    };
532
533    write!(writer, "{}{} {}", indent, bullet.bright_magenta(), checkbox)?;
534
535    let mut has_content = false;
536    for value in &list.values {
537        match value {
538            Node::List(nested_list) => {
539                if has_content {
540                    writeln!(writer)?; // New line before nested list only if we had content
541                }
542                render_list(nested_list, depth + 1, highlighter, config, writer)?;
543            }
544            Node::Fragment(fragment) => {
545                // Handle paragraph content inline
546                for child in &fragment.values {
547                    render_node_inline(child, depth + 1, true, highlighter, config, writer)?;
548                }
549                has_content = true;
550            }
551            _ => {
552                render_node_inline(value, depth + 1, true, highlighter, config, writer)?;
553                has_content = true;
554            }
555        }
556    }
557
558    writeln!(writer)?; // Add line break after list item
559    Ok(())
560}
561
562fn render_callout_blockquote<W: Write>(
563    blockquote: &mq_markdown::Blockquote,
564    _depth: usize,
565    highlighter: &mut SyntaxHighlighter,
566    config: &RenderConfig,
567    writer: &mut W,
568) -> io::Result<()> {
569    // Find the callout type from any text node in the blockquote
570    let mut callout_info = None;
571    let mut callout_text = String::new();
572
573    for value in &blockquote.values {
574        match value {
575            Node::Fragment(para) => {
576                for child in &para.values {
577                    if let Node::Text(text) = child
578                        && let Some(callout) = detect_callout(&text.value)
579                    {
580                        callout_info = Some(callout);
581                        // Extract content after the callout marker
582                        if let Some(end) = text.value.find(']') {
583                            callout_text = text.value[end + 1..].trim_start().to_string();
584                        }
585                        break;
586                    }
587                }
588            }
589            Node::Text(text) => {
590                if let Some(callout) = detect_callout(&text.value) {
591                    callout_info = Some(callout);
592                    if let Some(end) = text.value.find(']') {
593                        callout_text = text.value[end + 1..].trim_start().to_string();
594                    }
595                    break;
596                }
597            }
598            _ => {}
599        }
600        if callout_info.is_some() {
601            break;
602        }
603    }
604
605    if let Some(callout) = callout_info {
606        // Print the callout header
607        let header = format!("{} {}", callout.icon, callout.name)
608            .color(callout.color)
609            .bold();
610        writeln!(writer, "┌─ {}", header)?;
611
612        // Print the content
613        if !callout_text.is_empty() {
614            writeln!(writer, "│ {}", callout_text)?;
615        }
616
617        // Print remaining content from blockquote
618        let mut found_callout_marker = false;
619        for value in &blockquote.values {
620            match value {
621                Node::Fragment(para) => {
622                    let mut line_content = String::new();
623                    for child in &para.values {
624                        match child {
625                            Node::Text(text) => {
626                                if !found_callout_marker && detect_callout(&text.value).is_some() {
627                                    found_callout_marker = true;
628                                    // Skip the callout marker part
629                                    if let Some(end) = text.value.find(']') {
630                                        let remaining = text.value[end + 1..].trim_start();
631                                        if !remaining.is_empty() {
632                                            line_content.push_str(remaining);
633                                        }
634                                    }
635                                } else {
636                                    line_content.push_str(&text.value);
637                                }
638                            }
639                            Node::Link(link) => {
640                                let text = render_inline_content(&link.values);
641                                let url = link.url.as_str();
642                                if text.trim().is_empty() {
643                                    line_content.push_str(&format!(
644                                        " 🔗 {}",
645                                        make_clickable_link(url, url)
646                                    ));
647                                } else {
648                                    line_content.push_str(&format!(
649                                        " 🔗 {}",
650                                        make_clickable_link(url, &text)
651                                    ));
652                                }
653                            }
654                            _ => {
655                                // Handle all other inline formatting
656                                line_content
657                                    .push_str(&render_inline_content(std::slice::from_ref(child)));
658                            }
659                        }
660                    }
661                    if !line_content.trim().is_empty() && found_callout_marker {
662                        writeln!(writer, "│ {}", line_content)?;
663                    }
664                }
665                _ => {
666                    if found_callout_marker {
667                        write!(writer, "│ ")?;
668                        render_node_inline(value, 0, false, highlighter, config, writer)?;
669                    }
670                }
671            }
672        }
673
674        writeln!(writer, "└─")?;
675    }
676    Ok(())
677}
678
679fn render_regular_blockquote<W: Write>(
680    blockquote: &mq_markdown::Blockquote,
681    depth: usize,
682    highlighter: &mut SyntaxHighlighter,
683    config: &RenderConfig,
684    writer: &mut W,
685) -> io::Result<()> {
686    for value in &blockquote.values {
687        write!(writer, "{} ", "▌".bright_black())?;
688        render_node_inline(value, depth, false, highlighter, config, writer)?;
689    }
690    Ok(())
691}
692
693fn render_inline_content(nodes: &[Node]) -> String {
694    let mut result = String::new();
695    for (i, node) in nodes.iter().enumerate() {
696        // Add space between inline elements if needed
697        if i > 0 && needs_space_before(node) && !result.ends_with(' ') {
698            result.push(' ');
699        }
700
701        match node {
702            Node::Text(text) => result.push_str(&text.value),
703            Node::CodeInline(code) => result.push_str(&format!("`{}`", code.value)),
704            Node::Strong(strong) => result.push_str(&render_inline_content(&strong.values)),
705            Node::Emphasis(emphasis) => result.push_str(&render_inline_content(&emphasis.values)),
706            Node::Link(link) => {
707                let text = render_inline_content(&link.values);
708                let url = link.url.as_str();
709                if text.trim().is_empty() {
710                    result.push_str(&format!("🔗 {}", make_clickable_link(url, url)));
711                } else {
712                    result.push_str(&format!("🔗 {}", make_clickable_link(url, &text)));
713                }
714            }
715            _ => {}
716        }
717    }
718    result
719}
720
721fn needs_space_before(node: &Node) -> bool {
722    matches!(
723        node,
724        Node::Link(_) | Node::Strong(_) | Node::Emphasis(_) | Node::CodeInline(_)
725    )
726}
727
728fn get_node_children(node: &Node) -> Option<&Vec<Node>> {
729    match node {
730        Node::Fragment(fragment) => Some(&fragment.values),
731        Node::TableRow(row) => Some(&row.values),
732        Node::TableCell(cell) => Some(&cell.values),
733        _ => None,
734    }
735}
736
737/// Render a complete table with proper column alignment
738fn render_table<W: Write>(
739    table_nodes: &[&Node],
740    highlighter: &mut SyntaxHighlighter,
741    writer: &mut W,
742) -> io::Result<()> {
743    if table_nodes.is_empty() {
744        return Ok(());
745    }
746
747    // Tables don't use full-width highlighting, use default config
748    let config = RenderConfig::default();
749
750    // Calculate column widths from all cells
751    let all_nodes: Vec<Node> = table_nodes.iter().map(|n| (*n).clone()).collect();
752    let column_widths = calculate_column_widths(&all_nodes);
753
754    // Find table header to determine column count
755    let col_count = table_nodes
756        .iter()
757        .find_map(|node| {
758            if let Node::TableAlign(header) = node {
759                Some(header.align.len())
760            } else {
761                None
762            }
763        })
764        .unwrap_or(column_widths.len());
765
766    writeln!(writer)?;
767
768    // Render top border
769    render_table_top_border(&column_widths, col_count, writer)?;
770
771    // Render cells row by row
772    write!(writer, "{}", "│ ".bright_cyan())?;
773
774    for (i, node) in table_nodes.iter().enumerate() {
775        match node {
776            Node::TableCell(cell) => {
777                let content = render_inline_content(&cell.values);
778                let width = column_widths.get(cell.column).copied().unwrap_or(0);
779
780                for value in &cell.values {
781                    render_node_inline(value, 0, true, highlighter, &config, writer)?;
782                }
783
784                // Pad with spaces to align columns
785                let content_width = content.chars().count();
786                if content_width < width {
787                    write!(writer, "{}", " ".repeat(width - content_width))?;
788                }
789
790                write!(writer, " {}", "│ ".bright_cyan())?;
791
792                // Check if this is the last cell in its row
793                let is_last_in_row = match table_nodes.get(i + 1) {
794                    Some(Node::TableCell(next_cell)) => next_cell.row != cell.row,
795                    _ => true,
796                };
797
798                if is_last_in_row {
799                    writeln!(writer)?;
800                    // Check if next node is the header separator or another cell
801                    if i + 1 < table_nodes.len() {
802                        if let Some(Node::TableAlign(header)) = table_nodes.get(i + 1) {
803                            render_table_header(header, &column_widths, writer)?;
804                            // After header, if there's another cell, start a new row
805                            if i + 2 < table_nodes.len()
806                                && matches!(table_nodes.get(i + 2), Some(Node::TableCell(_)))
807                            {
808                                write!(writer, "{}", "│ ".bright_cyan())?;
809                            }
810                        } else if matches!(table_nodes.get(i + 1), Some(Node::TableCell(_))) {
811                            // Start new row
812                            write!(writer, "{}", "│ ".bright_cyan())?;
813                        }
814                    }
815                }
816            }
817            Node::TableAlign(_) => {
818                // Already handled in the TableCell last_cell_in_row logic
819            }
820            Node::TableRow(row) => {
821                render_table_row(row, &column_widths, highlighter, &config, writer)?;
822            }
823            _ => {}
824        }
825    }
826
827    // Render bottom border
828    render_table_bottom_border(&column_widths, col_count, writer)?;
829
830    writeln!(writer)?;
831    Ok(())
832}
833
834/// Calculate column widths for a table
835fn calculate_column_widths(nodes: &[Node]) -> Vec<usize> {
836    let mut column_widths: Vec<usize> = Vec::new();
837
838    for node in nodes {
839        match node {
840            Node::TableRow(row) => {
841                for (col_idx, cell_node) in row.values.iter().enumerate() {
842                    if let Node::TableCell(cell) = cell_node {
843                        let content = render_inline_content(&cell.values);
844                        let width = content.chars().count();
845
846                        if col_idx >= column_widths.len() {
847                            column_widths.resize(col_idx + 1, 0);
848                        }
849                        column_widths[col_idx] = column_widths[col_idx].max(width);
850                    }
851                }
852            }
853            Node::TableCell(cell) => {
854                let content = render_inline_content(&cell.values);
855                let width = content.chars().count();
856
857                if cell.column >= column_widths.len() {
858                    column_widths.resize(cell.column + 1, 0);
859                }
860                column_widths[cell.column] = column_widths[cell.column].max(width);
861            }
862            _ => {}
863        }
864    }
865
866    column_widths
867}
868
869/// Render table top border
870fn render_table_top_border<W: Write>(
871    column_widths: &[usize],
872    col_count: usize,
873    writer: &mut W,
874) -> io::Result<()> {
875    write!(writer, "{}", "┌".bright_black())?;
876    for i in 0..col_count {
877        let width = column_widths.get(i).copied().unwrap_or(4);
878        write!(writer, "{}", "─".repeat(width + 2).bright_black())?;
879        if i < col_count - 1 {
880            write!(writer, "{}", "┬".bright_black())?;
881        }
882    }
883    writeln!(writer, "{}", "┐".bright_black())?;
884    Ok(())
885}
886
887/// Render table bottom border
888fn render_table_bottom_border<W: Write>(
889    column_widths: &[usize],
890    col_count: usize,
891    writer: &mut W,
892) -> io::Result<()> {
893    write!(writer, "{}", "└".bright_black())?;
894    for i in 0..col_count {
895        let width = column_widths.get(i).copied().unwrap_or(4);
896        write!(writer, "{}", "─".repeat(width + 2).bright_black())?;
897        if i < col_count - 1 {
898            write!(writer, "{}", "┴".bright_black())?;
899        }
900    }
901    writeln!(writer, "{}", "┘".bright_black())?;
902    Ok(())
903}
904
905/// Render table header with alignment and column widths
906fn render_table_header<W: Write>(
907    header: &mq_markdown::TableAlign,
908    column_widths: &[usize],
909    writer: &mut W,
910) -> io::Result<()> {
911    write!(writer, "{}", "├".bright_black())?;
912    for (i, align) in header.align.iter().enumerate() {
913        let width = column_widths.get(i).copied().unwrap_or(4);
914        let (left, right) = match align {
915            mq_markdown::TableAlignKind::Left => (":", "─"),
916            mq_markdown::TableAlignKind::Right => ("─", ":"),
917            mq_markdown::TableAlignKind::Center => (":", ":"),
918            mq_markdown::TableAlignKind::None => ("─", "─"),
919        };
920
921        write!(writer, "{}", left.bright_black())?;
922        write!(writer, "{}", "─".repeat(width).bright_black())?;
923        write!(writer, "{}", right.bright_black())?;
924
925        if i < header.align.len() - 1 {
926            write!(writer, "{}", "┼".bright_black())?;
927        }
928    }
929    writeln!(writer, "{}", "┤".bright_black())?;
930    Ok(())
931}
932
933/// Render table row with column widths
934fn render_table_row<W: Write>(
935    row: &mq_markdown::TableRow,
936    column_widths: &[usize],
937    highlighter: &mut SyntaxHighlighter,
938    config: &RenderConfig,
939    writer: &mut W,
940) -> io::Result<()> {
941    write!(writer, "{}", "│ ".bright_cyan())?;
942    for (col_idx, cell_node) in row.values.iter().enumerate() {
943        if let Node::TableCell(cell) = cell_node {
944            let content = render_inline_content(&cell.values);
945            let width = column_widths.get(col_idx).copied().unwrap_or(0);
946
947            for value in &cell.values {
948                render_node_inline(value, 0, true, highlighter, config, writer)?;
949            }
950
951            // Pad with spaces to align columns
952            let content_width = content.chars().count();
953            if content_width < width {
954                write!(writer, "{}", " ".repeat(width - content_width))?;
955            }
956
957            write!(writer, " {}", "│ ".bright_cyan())?;
958        }
959    }
960    writeln!(writer)?;
961    Ok(())
962}
963
964/// Render table cell with column width
965fn render_table_cell<W: Write>(
966    cell: &mq_markdown::TableCell,
967    column_widths: &[usize],
968    highlighter: &mut SyntaxHighlighter,
969    config: &RenderConfig,
970    writer: &mut W,
971) -> io::Result<()> {
972    write!(writer, "{}", "│ ".bright_cyan())?;
973
974    let content = render_inline_content(&cell.values);
975    let width = column_widths.get(cell.column).copied().unwrap_or(0);
976
977    for value in &cell.values {
978        render_node_inline(value, 0, true, highlighter, config, writer)?;
979    }
980
981    // Pad with spaces to align columns
982    let content_width = content.chars().count();
983    if content_width < width {
984        write!(writer, "{}", " ".repeat(width - content_width))?;
985    }
986
987    write!(writer, " ")?;
988    writeln!(writer, "{}", "│".bright_cyan())?;
989    Ok(())
990}
991
992/// Render an image to the terminal if possible
993fn render_image_to_terminal(path: &str) -> io::Result<()> {
994    // Check if the path is a local file
995    if path.starts_with("http://") || path.starts_with("https://") {
996        // For remote images, we would need to download them first
997        // For now, skip rendering remote images
998        return Ok(());
999    }
1000
1001    let image_path = Path::new(path);
1002    if !image_path.exists() {
1003        return Ok(());
1004    }
1005
1006    // Use viuer to display the image with default configuration
1007    // This will auto-detect the best protocol (Kitty, iTerm2, Sixel, or blocks)
1008    let conf = viuer::Config {
1009        width: Some(60),
1010        height: None,
1011        absolute_offset: false,
1012        ..Default::default()
1013    };
1014
1015    // Try to open and display the image
1016    if let Ok(img) = image::open(path) {
1017        let _ = viuer::print(&img, &conf);
1018    }
1019
1020    Ok(())
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026    use mq_markdown::{Markdown, Node};
1027
1028    #[test]
1029    fn test_render_markdown_to_string_simple_text() {
1030        let markdown: Markdown = "Hello World".parse().unwrap();
1031        let result = render_markdown_to_string(&markdown).unwrap();
1032        assert!(result.contains("Hello World"));
1033    }
1034
1035    #[test]
1036    fn test_render_markdown_to_string_heading() {
1037        let markdown: Markdown = "# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n##### Heading 5\n###### Heading 6\n".parse().unwrap();
1038        let result = render_markdown_to_string(&markdown).unwrap();
1039        assert!(result.contains("Heading 1"));
1040        assert!(result.contains("Heading 2"));
1041        assert!(result.contains("Heading 3"));
1042        assert!(result.contains("Heading 4"));
1043        assert!(result.contains("Heading 5"));
1044        assert!(result.contains("Heading 6"));
1045    }
1046
1047    #[test]
1048    fn test_render_markdown_to_string_list() {
1049        let markdown: Markdown = "- Item 1\n- Item 2\n- Item 3".parse().unwrap();
1050        let result = render_markdown_to_string(&markdown).unwrap();
1051        assert!(result.contains("Item 1"));
1052        assert!(result.contains("Item 2"));
1053        assert!(result.contains("Item 3"));
1054    }
1055
1056    #[test]
1057    fn test_render_markdown_to_string_code_block() {
1058        let markdown: Markdown = "```rust\nfn main() {}\n```".parse().unwrap();
1059        let result = render_markdown_to_string(&markdown).unwrap();
1060        // Code blocks may be syntax highlighted, so just check for the function name
1061        assert!(result.contains("main"));
1062    }
1063
1064    #[test]
1065    fn test_render_markdown_to_string_inline_code() {
1066        let markdown: Markdown = "This is `inline code` text".parse().unwrap();
1067        let result = render_markdown_to_string(&markdown).unwrap();
1068        assert!(result.contains("inline code"));
1069    }
1070
1071    #[test]
1072    fn test_render_markdown_to_string_bold() {
1073        let markdown: Markdown = "This is **bold** text".parse().unwrap();
1074        let result = render_markdown_to_string(&markdown).unwrap();
1075        assert!(result.contains("bold"));
1076    }
1077
1078    #[test]
1079    fn test_render_markdown_to_string_italic() {
1080        let markdown: Markdown = "This is *italic* text".parse().unwrap();
1081        let result = render_markdown_to_string(&markdown).unwrap();
1082        assert!(result.contains("italic"));
1083    }
1084
1085    #[test]
1086    fn test_render_markdown_to_string_link() {
1087        let markdown: Markdown = "[Link Text](https://example.com)".parse().unwrap();
1088        let result = render_markdown_to_string(&markdown).unwrap();
1089        assert!(result.contains("Link Text"));
1090    }
1091
1092    #[test]
1093    fn test_render_markdown_to_string_blockquote() {
1094        let markdown: Markdown = "> This is a quote".parse().unwrap();
1095        let result = render_markdown_to_string(&markdown).unwrap();
1096        assert!(result.contains("This is a quote"));
1097    }
1098
1099    #[test]
1100    fn test_render_markdown_to_string_horizontal_rule() {
1101        let markdown: Markdown = "---".parse().unwrap();
1102        let result = render_markdown_to_string(&markdown).unwrap();
1103        // Check that some separator is rendered
1104        assert!(!result.is_empty());
1105    }
1106
1107    #[test]
1108    fn test_detect_callout_note() {
1109        assert!(detect_callout("[!NOTE] Test").is_some());
1110    }
1111
1112    #[test]
1113    fn test_detect_callout_tip() {
1114        assert!(detect_callout("[!TIP] Test").is_some());
1115    }
1116
1117    #[test]
1118    fn test_detect_callout_important() {
1119        assert!(detect_callout("[!IMPORTANT] Test").is_some());
1120    }
1121
1122    #[test]
1123    fn test_detect_callout_warning() {
1124        assert!(detect_callout("[!WARNING] Test").is_some());
1125    }
1126
1127    #[test]
1128    fn test_detect_callout_caution() {
1129        assert!(detect_callout("[!CAUTION] Test").is_some());
1130    }
1131
1132    #[test]
1133    fn test_detect_callout_case_insensitive() {
1134        assert!(detect_callout("[!note] Test").is_some());
1135        assert!(detect_callout("[!Note] Test").is_some());
1136    }
1137
1138    #[test]
1139    fn test_detect_callout_none() {
1140        assert!(detect_callout("Regular text").is_none());
1141        assert!(detect_callout("[NOTE] No exclamation").is_none());
1142    }
1143
1144    #[test]
1145    fn test_make_clickable_link() {
1146        let link = make_clickable_link("https://example.com", "Example");
1147        assert!(link.contains("https://example.com"));
1148        assert!(link.contains("Example"));
1149    }
1150
1151    #[test]
1152    fn test_render_inline_content_text() {
1153        let nodes = vec![Node::Text(mq_markdown::Text {
1154            value: "Hello".to_string(),
1155            position: None,
1156        })];
1157        let result = render_inline_content(&nodes);
1158        assert_eq!(result, "Hello");
1159    }
1160
1161    #[test]
1162    fn test_render_inline_content_inline_code() {
1163        let nodes = vec![Node::CodeInline(mq_markdown::CodeInline {
1164            value: "code".into(),
1165            position: None,
1166        })];
1167        let result = render_inline_content(&nodes);
1168        assert_eq!(result, "`code`");
1169    }
1170
1171    #[test]
1172    fn test_render_inline_content_strong() {
1173        let nodes = vec![Node::Strong(mq_markdown::Strong {
1174            values: vec![Node::Text(mq_markdown::Text {
1175                value: "bold".to_string(),
1176                position: None,
1177            })],
1178            position: None,
1179        })];
1180        let result = render_inline_content(&nodes);
1181        assert_eq!(result, "bold");
1182    }
1183
1184    #[test]
1185    fn test_render_inline_content_emphasis() {
1186        let nodes = vec![Node::Emphasis(mq_markdown::Emphasis {
1187            values: vec![Node::Text(mq_markdown::Text {
1188                value: "italic".to_string(),
1189                position: None,
1190            })],
1191            position: None,
1192        })];
1193        let result = render_inline_content(&nodes);
1194        assert_eq!(result, "italic");
1195    }
1196
1197    #[test]
1198    fn test_needs_space_before() {
1199        // Test with actual parsed markdown to avoid manual construction
1200        let markdown: Markdown = "[link](url) **bold** *italic* `code` text".parse().unwrap();
1201
1202        // Extract nodes from parsed markdown
1203        if let Some(Node::Fragment(fragment)) = markdown.nodes.first() {
1204            for node in &fragment.values {
1205                match node {
1206                    Node::Link(_) => assert!(needs_space_before(node)),
1207                    Node::Strong(_) => assert!(needs_space_before(node)),
1208                    Node::Emphasis(_) => assert!(needs_space_before(node)),
1209                    Node::CodeInline(_) => assert!(needs_space_before(node)),
1210                    Node::Text(_) => assert!(!needs_space_before(node)),
1211                    _ => {}
1212                }
1213            }
1214        }
1215    }
1216
1217    #[test]
1218    fn test_calculate_column_widths() {
1219        let nodes = vec![
1220            Node::TableCell(mq_markdown::TableCell {
1221                values: vec![Node::Text(mq_markdown::Text {
1222                    value: "Short".to_string(),
1223                    position: None,
1224                })],
1225                column: 0,
1226                row: 0,
1227                position: None,
1228            }),
1229            Node::TableCell(mq_markdown::TableCell {
1230                values: vec![Node::Text(mq_markdown::Text {
1231                    value: "Very Long Text".to_string(),
1232                    position: None,
1233                })],
1234                column: 1,
1235                row: 0,
1236                position: None,
1237            }),
1238        ];
1239        let widths = calculate_column_widths(&nodes);
1240        assert_eq!(widths[0], 5); // "Short"
1241        assert_eq!(widths[1], 14); // "Very Long Text"
1242    }
1243
1244    #[test]
1245    fn test_render_markdown_ordered_list() {
1246        let markdown: Markdown = "1. First\n2. Second\n3. Third".parse().unwrap();
1247        let result = render_markdown_to_string(&markdown).unwrap();
1248        assert!(result.contains("First"));
1249        assert!(result.contains("Second"));
1250        assert!(result.contains("Third"));
1251    }
1252
1253    #[test]
1254    fn test_render_markdown_checkbox_list() {
1255        let markdown: Markdown = "- [x] Done\n- [ ] Todo".parse().unwrap();
1256        let result = render_markdown_to_string(&markdown).unwrap();
1257        assert!(result.contains("Done"));
1258        assert!(result.contains("Todo"));
1259    }
1260
1261    #[test]
1262    fn test_render_markdown_table() {
1263        let markdown: Markdown =
1264            "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |"
1265                .parse()
1266                .unwrap();
1267        let result = render_markdown_to_string(&markdown).unwrap();
1268        assert!(result.contains("Header 1"));
1269        assert!(result.contains("Header 2"));
1270        assert!(result.contains("Cell 1"));
1271        assert!(result.contains("Cell 2"));
1272    }
1273
1274    #[test]
1275    fn test_render_markdown_nested_list() {
1276        let markdown: Markdown = "- Item 1\n  - Nested 1\n  - Nested 2\n- Item 2"
1277            .parse()
1278            .unwrap();
1279        let result = render_markdown_to_string(&markdown).unwrap();
1280        assert!(result.contains("Item 1"));
1281        assert!(result.contains("Nested 1"));
1282        assert!(result.contains("Nested 2"));
1283        assert!(result.contains("Item 2"));
1284    }
1285
1286    #[test]
1287    fn test_render_markdown_mixed_formatting() {
1288        let markdown: Markdown = "**Bold** and *italic* with `code`".parse().unwrap();
1289        let result = render_markdown_to_string(&markdown).unwrap();
1290        assert!(result.contains("Bold"));
1291        assert!(result.contains("italic"));
1292        assert!(result.contains("code"));
1293    }
1294
1295    #[test]
1296    fn test_render_callout_blockquote_note() {
1297        let markdown: Markdown = "> [!NOTE] This is a note callout\n> Additional info"
1298            .parse()
1299            .unwrap();
1300        let result = render_markdown_to_string(&markdown).unwrap();
1301        // The callout icon and name should be present
1302        assert!(result.contains("ℹ️"));
1303        assert!(result.contains("Note"));
1304        assert!(result.contains("This is a note callout"));
1305        assert!(result.contains("Additional info"));
1306        // Should have box drawing characters
1307        assert!(result.contains("┌─"));
1308        assert!(result.contains("└─"));
1309    }
1310
1311    #[test]
1312    fn test_render_callout_blockquote_tip() {
1313        let markdown: Markdown = "> [!TIP] This is a tip callout".parse().unwrap();
1314        let result = render_markdown_to_string(&markdown).unwrap();
1315        assert!(result.contains("💡"));
1316        assert!(result.contains("Tip"));
1317        assert!(result.contains("This is a tip callout"));
1318        assert!(result.contains("┌─"));
1319        assert!(result.contains("└─"));
1320    }
1321
1322    #[test]
1323    fn test_render_callout_blockquote_important() {
1324        let markdown: Markdown = "> [!IMPORTANT] Important info".parse().unwrap();
1325        let result = render_markdown_to_string(&markdown).unwrap();
1326        assert!(result.contains("❗"));
1327        assert!(result.contains("Important"));
1328        assert!(result.contains("Important info"));
1329        assert!(result.contains("┌─"));
1330        assert!(result.contains("└─"));
1331    }
1332
1333    #[test]
1334    fn test_render_callout_blockquote_warning() {
1335        let markdown: Markdown = "> [!WARNING] Warning info".parse().unwrap();
1336        let result = render_markdown_to_string(&markdown).unwrap();
1337        assert!(result.contains("⚠️"));
1338        assert!(result.contains("Warning"));
1339        assert!(result.contains("Warning info"));
1340        assert!(result.contains("┌─"));
1341        assert!(result.contains("└─"));
1342    }
1343
1344    #[test]
1345    fn test_render_callout_blockquote_caution() {
1346        let markdown: Markdown = "> [!CAUTION] Caution info".parse().unwrap();
1347        let result = render_markdown_to_string(&markdown).unwrap();
1348        assert!(result.contains("🔥"));
1349        assert!(result.contains("Caution"));
1350        assert!(result.contains("Caution info"));
1351        assert!(result.contains("┌─"));
1352        assert!(result.contains("└─"));
1353    }
1354
1355    #[test]
1356    fn test_render_callout_blockquote_case_insensitive() {
1357        let markdown: Markdown = "> [!note] lower case note\n\n> [!Tip] mixed case tip"
1358            .parse()
1359            .unwrap();
1360        let result = render_markdown_to_string(&markdown).unwrap();
1361        assert!(result.contains("ℹ️"));
1362        assert!(result.contains("Note"));
1363        assert!(result.contains("lower case note"));
1364        assert!(result.contains("💡"));
1365        assert!(result.contains("Tip"));
1366        assert!(result.contains("mixed case tip"));
1367    }
1368
1369    #[test]
1370    fn test_render_markdown_html_block() {
1371        let markdown: Markdown = "<div>Hello HTML</div>".parse().unwrap();
1372        let result = render_markdown_to_string(&markdown).unwrap();
1373        // Should contain the HTML content
1374        assert!(result.contains("Hello HTML"));
1375        // Should contain some syntax highlighting (colored output)
1376        assert!(result.contains("\x1b"));
1377    }
1378
1379    #[test]
1380    fn test_render_markdown_inline_html() {
1381        let markdown: Markdown = "Text <span>inline html</span> more text".parse().unwrap();
1382        let result = render_markdown_to_string(&markdown).unwrap();
1383        assert!(result.contains("inline html"));
1384        assert!(result.contains("Text"));
1385        assert!(result.contains("more text"));
1386    }
1387
1388    #[test]
1389    fn test_render_markdown_image_with_alt() {
1390        let markdown: Markdown = "![Alt text](image.png)".parse().unwrap();
1391        let result = render_markdown_to_string(&markdown).unwrap();
1392        assert!(result.contains("🖼️"));
1393        assert!(result.contains("Alt text"));
1394        assert!(result.contains("image.png"));
1395    }
1396
1397    #[test]
1398    fn test_render_markdown_image_without_alt() {
1399        let markdown: Markdown = "![](image.png)".parse().unwrap();
1400        let result = render_markdown_to_string(&markdown).unwrap();
1401        assert!(result.contains("🖼️"));
1402        assert!(result.contains("image.png"));
1403    }
1404
1405    #[test]
1406    fn test_render_markdown_remote_image() {
1407        let markdown: Markdown = "![Remote](https://example.com/image.png)".parse().unwrap();
1408        let result = render_markdown_to_string(&markdown).unwrap();
1409        assert!(result.contains("🖼️"));
1410        assert!(result.contains("Remote"));
1411        assert!(result.contains("https://example.com/image.png"));
1412    }
1413
1414    #[test]
1415    fn test_render_markdown_table_with_alignment() {
1416        let markdown: Markdown = r#"
1417| Left | Center | Right |
1418|:-----|:------:|------:|
1419| L1   | C1     | R1    |
1420| L2   | C2     | R2    |
1421"#
1422        .parse()
1423        .unwrap();
1424        let result = render_markdown_to_string(&markdown).unwrap();
1425        assert!(result.contains("Left"));
1426        assert!(result.contains("Center"));
1427        assert!(result.contains("Right"));
1428        assert!(result.contains("L1"));
1429        assert!(result.contains("C1"));
1430        assert!(result.contains("R1"));
1431        assert!(result.contains("L2"));
1432        assert!(result.contains("C2"));
1433        assert!(result.contains("R2"));
1434        // Check for alignment markers in header border
1435        assert!(result.contains(":"));
1436    }
1437
1438    #[test]
1439    fn test_render_markdown_table_with_inline_formatting() {
1440        let markdown: Markdown = r#"
1441| **Bold** | *Italic* | `Code` |
1442|----------|----------|--------|
1443| A        | B        | C      |
1444"#
1445        .parse()
1446        .unwrap();
1447        let result = render_markdown_to_string(&markdown).unwrap();
1448        assert!(result.contains("Bold"));
1449        assert!(result.contains("Italic"));
1450        assert!(result.contains("Code"));
1451        assert!(result.contains("A"));
1452        assert!(result.contains("B"));
1453        assert!(result.contains("C"));
1454    }
1455
1456    #[test]
1457    fn test_render_markdown_table_with_links_and_images() {
1458        let markdown: Markdown = r#"
1459| Link | Image |
1460|------|-------|
1461| [Google](https://google.com) | ![Alt](img.png) |
1462"#
1463        .parse()
1464        .unwrap();
1465        let result = render_markdown_to_string(&markdown).unwrap();
1466        assert!(result.contains("Google"));
1467        assert!(result.contains("https://google.com"));
1468        assert!(result.contains("🖼️"));
1469        assert!(result.contains("Alt"));
1470        assert!(result.contains("img.png"));
1471    }
1472
1473    #[test]
1474    fn test_render_markdown_table_empty_cells() {
1475        let markdown: Markdown = r#"
1476| A | B | C |
1477|---|---|---|
1478|   | 1 |   |
1479| 2 |   | 3 |
1480"#
1481        .parse()
1482        .unwrap();
1483        let result = render_markdown_to_string(&markdown).unwrap();
1484        assert!(result.contains("A"));
1485        assert!(result.contains("B"));
1486        assert!(result.contains("C"));
1487        assert!(result.contains("1"));
1488        assert!(result.contains("2"));
1489        assert!(result.contains("3"));
1490    }
1491
1492    #[test]
1493    fn test_render_markdown_table_with_multiple_rows_and_columns() {
1494        let markdown: Markdown = r#"
1495| Col1 | Col2 | Col3 | Col4 |
1496|------|------|------|------|
1497| A    | B    | C    | D    |
1498| E    | F    | G    | H    |
1499| I    | J    | K    | L    |
1500"#
1501        .parse()
1502        .unwrap();
1503        let result = render_markdown_to_string(&markdown).unwrap();
1504        for val in &[
1505            "Col1", "Col2", "Col3", "Col4", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K",
1506            "L",
1507        ] {
1508            assert!(result.contains(val));
1509        }
1510    }
1511
1512    #[test]
1513    fn test_render_markdown_table_with_rowspan_and_colspan_like_content() {
1514        // Markdown tables do not support rowspan/colspan, but test for cells with multiline content
1515        let markdown: Markdown = r#"
1516| Header |
1517|--------|
1518| Line 1<br>Line 2 |
1519"#
1520        .parse()
1521        .unwrap();
1522        let result = render_markdown_to_string(&markdown).unwrap();
1523        assert!(result.contains("Header"));
1524        assert!(result.contains("Line 1"));
1525        assert!(result.contains("Line 2"));
1526    }
1527}