markdown: Add support for `colspan` and `rowspan` for HTML tables (#39898)

Remco Smits , Zed AI , and Anthony Eid created

Closes https://github.com/zed-industries/zed/issues/39837

This PR adds support for `colspan` feature that is only supported for
HTML tables. I also fixed an edge case where the right side border was
not applied because it didn't match the total column count.

**Before**
<img width="725" height="179"
alt="499166907-385cc787-fc89-4e6d-bf06-c72c3c0bd775"
src="https://github.com/user-attachments/assets/69586053-9893-4c92-aa89-7830d2bc7a6d"
/>

**After**
<img width="1165" height="180" alt="Screenshot 2025-10-21 at 22 51 55"
src="https://github.com/user-attachments/assets/f40686e7-d95b-45a6-be42-e226e2f77483"
/>

```html
<table>
    <tr>
        <th rowspan="2">Region</th>
        <th colspan="2">Revenue</th>
        <th rowspan="2">Growth</th>
    </tr>
    <tr>
        <th>Q2 2024</th>
        <th>Q3 2024</th>
    </tr>
    <tr>
        <td>North America</td>
        <td>$2.8M</td>
        <td>$2.4B</td>
        <td>+85,614%</td>
    </tr>
    <tr>
        <td>Europe</td>
        <td>$1.2M</td>
        <td>$1.9B</td>
        <td>+158,233%</td>
    </tr>
    <tr>
        <td>Asia-Pacific</td>
        <td>$0.5M</td>
        <td>$1.4B</td>
        <td>+279,900%</td>
    </tr>
</table>
```

**TODO**:
- [x] Add tests for rending logic
- [x] Test all the tables again

cc @bennetbo

Release Notes:

- Markdown: Added support for `colspan` and `rowspan` for HTML tables

---------

Co-authored-by: Zed AI <ai@zed.nl>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>

Change summary

crates/markdown_preview/src/markdown_elements.rs |  23 
crates/markdown_preview/src/markdown_parser.rs   | 175 +++++++-
crates/markdown_preview/src/markdown_renderer.rs | 334 +++++++++++------
3 files changed, 379 insertions(+), 153 deletions(-)

Detailed changes

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -104,25 +104,34 @@ pub enum HeadingLevel {
 #[derive(Debug)]
 pub struct ParsedMarkdownTable {
     pub source_range: Range<usize>,
-    pub header: ParsedMarkdownTableRow,
+    pub header: Vec<ParsedMarkdownTableRow>,
     pub body: Vec<ParsedMarkdownTableRow>,
     pub column_alignments: Vec<ParsedMarkdownTableAlignment>,
 }
 
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, Default)]
 #[cfg_attr(test, derive(PartialEq))]
 pub enum ParsedMarkdownTableAlignment {
-    /// Default text alignment.
+    #[default]
     None,
     Left,
     Center,
     Right,
 }
 
+#[derive(Debug)]
+#[cfg_attr(test, derive(PartialEq))]
+pub struct ParsedMarkdownTableColumn {
+    pub col_span: usize,
+    pub row_span: usize,
+    pub is_header: bool,
+    pub children: MarkdownParagraph,
+}
+
 #[derive(Debug)]
 #[cfg_attr(test, derive(PartialEq))]
 pub struct ParsedMarkdownTableRow {
-    pub children: Vec<MarkdownParagraph>,
+    pub columns: Vec<ParsedMarkdownTableColumn>,
 }
 
 impl Default for ParsedMarkdownTableRow {
@@ -134,12 +143,12 @@ impl Default for ParsedMarkdownTableRow {
 impl ParsedMarkdownTableRow {
     pub fn new() -> Self {
         Self {
-            children: Vec::new(),
+            columns: Vec::new(),
         }
     }
 
-    pub fn with_children(children: Vec<MarkdownParagraph>) -> Self {
-        Self { children }
+    pub fn with_columns(columns: Vec<ParsedMarkdownTableColumn>) -> Self {
+        Self { columns }
     }
 }
 

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -462,9 +462,9 @@ impl<'a> MarkdownParser<'a> {
     fn parse_table(&mut self, alignment: Vec<Alignment>) -> ParsedMarkdownTable {
         let (_event, source_range) = self.previous().unwrap();
         let source_range = source_range.clone();
-        let mut header = ParsedMarkdownTableRow::new();
+        let mut header = vec![];
         let mut body = vec![];
-        let mut current_row = vec![];
+        let mut row_columns = vec![];
         let mut in_header = true;
         let column_alignments = alignment.iter().map(Self::convert_alignment).collect();
 
@@ -484,17 +484,21 @@ impl<'a> MarkdownParser<'a> {
                 Event::Start(Tag::TableCell) => {
                     self.cursor += 1;
                     let cell_contents = self.parse_text(false, Some(source_range));
-                    current_row.push(cell_contents);
+                    row_columns.push(ParsedMarkdownTableColumn {
+                        col_span: 1,
+                        row_span: 1,
+                        is_header: in_header,
+                        children: cell_contents,
+                    });
                 }
                 Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
                     self.cursor += 1;
-                    let new_row = std::mem::take(&mut current_row);
+                    let columns = std::mem::take(&mut row_columns);
                     if in_header {
-                        header.children = new_row;
+                        header.push(ParsedMarkdownTableRow { columns: columns });
                         in_header = false;
                     } else {
-                        let row = ParsedMarkdownTableRow::with_children(new_row);
-                        body.push(row);
+                        body.push(ParsedMarkdownTableRow::with_columns(columns));
                     }
                 }
                 Event::End(TagEnd::Table) => {
@@ -941,6 +945,70 @@ impl<'a> MarkdownParser<'a> {
         }
     }
 
+    fn parse_table_row(
+        &self,
+        source_range: Range<usize>,
+        node: &Rc<markup5ever_rcdom::Node>,
+    ) -> Option<ParsedMarkdownTableRow> {
+        let mut columns = Vec::new();
+
+        match &node.data {
+            markup5ever_rcdom::NodeData::Element { name, .. } => {
+                if local_name!("tr") != name.local {
+                    return None;
+                }
+
+                for node in node.children.borrow().iter() {
+                    if let Some(column) = self.parse_table_column(source_range.clone(), node) {
+                        columns.push(column);
+                    }
+                }
+            }
+            _ => {}
+        }
+
+        if columns.is_empty() {
+            None
+        } else {
+            Some(ParsedMarkdownTableRow { columns })
+        }
+    }
+
+    fn parse_table_column(
+        &self,
+        source_range: Range<usize>,
+        node: &Rc<markup5ever_rcdom::Node>,
+    ) -> Option<ParsedMarkdownTableColumn> {
+        match &node.data {
+            markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
+                if !matches!(name.local, local_name!("th") | local_name!("td")) {
+                    return None;
+                }
+
+                let mut children = MarkdownParagraph::new();
+                self.consume_paragraph(source_range, node, &mut children);
+
+                Some(ParsedMarkdownTableColumn {
+                    col_span: std::cmp::max(
+                        Self::attr_value(attrs, local_name!("colspan"))
+                            .and_then(|span| span.parse().ok())
+                            .unwrap_or(1),
+                        1,
+                    ),
+                    row_span: std::cmp::max(
+                        Self::attr_value(attrs, local_name!("rowspan"))
+                            .and_then(|span| span.parse().ok())
+                            .unwrap_or(1),
+                        1,
+                    ),
+                    is_header: matches!(name.local, local_name!("th")),
+                    children,
+                })
+            }
+            _ => None,
+        }
+    }
+
     fn consume_children(
         &self,
         source_range: Range<usize>,
@@ -1056,7 +1124,7 @@ impl<'a> MarkdownParser<'a> {
         node: &Rc<markup5ever_rcdom::Node>,
         source_range: Range<usize>,
     ) -> Option<ParsedMarkdownTable> {
-        let mut header_columns = Vec::new();
+        let mut header_rows = Vec::new();
         let mut body_rows = Vec::new();
 
         // node should be a thead or tbody element
@@ -1066,21 +1134,16 @@ impl<'a> MarkdownParser<'a> {
                     if local_name!("thead") == name.local {
                         // node should be a tr element
                         for node in node.children.borrow().iter() {
-                            let mut paragraph = MarkdownParagraph::new();
-                            self.consume_paragraph(source_range.clone(), node, &mut paragraph);
-
-                            for paragraph in paragraph.into_iter() {
-                                header_columns.push(vec![paragraph]);
+                            if let Some(row) = self.parse_table_row(source_range.clone(), node) {
+                                header_rows.push(row);
                             }
                         }
                     } else if local_name!("tbody") == name.local {
                         // node should be a tr element
                         for node in node.children.borrow().iter() {
-                            let mut row = MarkdownParagraph::new();
-                            self.consume_paragraph(source_range.clone(), node, &mut row);
-                            body_rows.push(ParsedMarkdownTableRow::with_children(
-                                row.into_iter().map(|column| vec![column]).collect(),
-                            ));
+                            if let Some(row) = self.parse_table_row(source_range.clone(), node) {
+                                body_rows.push(row);
+                            }
                         }
                     }
                 }
@@ -1088,12 +1151,12 @@ impl<'a> MarkdownParser<'a> {
             }
         }
 
-        if !header_columns.is_empty() || !body_rows.is_empty() {
+        if !header_rows.is_empty() || !body_rows.is_empty() {
             Some(ParsedMarkdownTable {
                 source_range,
                 body: body_rows,
                 column_alignments: Vec::default(),
-                header: ParsedMarkdownTableRow::with_children(header_columns),
+                header: header_rows,
             })
         } else {
             None
@@ -1589,10 +1652,19 @@ mod tests {
             ParsedMarkdown {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..366,
-                    row(vec![text("Id", 0..366), text("Name ", 0..366)]),
+                    vec![row(vec![
+                        column(1, 1, true, text("Id", 0..366)),
+                        column(1, 1, true, text("Name ", 0..366))
+                    ])],
                     vec![
-                        row(vec![text("1", 0..366), text("Chris", 0..366)]),
-                        row(vec![text("2", 0..366), text("Dennis", 0..366)]),
+                        row(vec![
+                            column(1, 1, false, text("1", 0..366)),
+                            column(1, 1, false, text("Chris", 0..366))
+                        ]),
+                        row(vec![
+                            column(1, 1, false, text("2", 0..366)),
+                            column(1, 1, false, text("Dennis", 0..366))
+                        ]),
                     ],
                 ))],
             },
@@ -1622,10 +1694,16 @@ mod tests {
             ParsedMarkdown {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..240,
-                    row(vec![]),
+                    vec![],
                     vec![
-                        row(vec![text("1", 0..240), text("Chris", 0..240)]),
-                        row(vec![text("2", 0..240), text("Dennis", 0..240)]),
+                        row(vec![
+                            column(1, 1, false, text("1", 0..240)),
+                            column(1, 1, false, text("Chris", 0..240))
+                        ]),
+                        row(vec![
+                            column(1, 1, false, text("2", 0..240)),
+                            column(1, 1, false, text("Dennis", 0..240))
+                        ]),
                     ],
                 ))],
             },
@@ -1651,7 +1729,10 @@ mod tests {
             ParsedMarkdown {
                 children: vec![ParsedMarkdownElement::Table(table(
                     0..150,
-                    row(vec![text("Id", 0..150), text("Name", 0..150)]),
+                    vec![row(vec![
+                        column(1, 1, true, text("Id", 0..150)),
+                        column(1, 1, true, text("Name", 0..150))
+                    ])],
                     vec![],
                 ))],
             },
@@ -1833,7 +1914,10 @@ Some other content
 
         let expected_table = table(
             0..48,
-            row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
+            vec![row(vec![
+                column(1, 1, true, text("Header 1", 1..11)),
+                column(1, 1, true, text("Header 2", 12..22)),
+            ])],
             vec![],
         );
 
@@ -1853,10 +1937,19 @@ Some other content
 
         let expected_table = table(
             0..95,
-            row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
+            vec![row(vec![
+                column(1, 1, true, text("Header 1", 1..11)),
+                column(1, 1, true, text("Header 2", 12..22)),
+            ])],
             vec![
-                row(vec![text("Cell 1", 49..59), text("Cell 2", 60..70)]),
-                row(vec![text("Cell 3", 73..83), text("Cell 4", 84..94)]),
+                row(vec![
+                    column(1, 1, false, text("Cell 1", 49..59)),
+                    column(1, 1, false, text("Cell 2", 60..70)),
+                ]),
+                row(vec![
+                    column(1, 1, false, text("Cell 3", 73..83)),
+                    column(1, 1, false, text("Cell 4", 84..94)),
+                ]),
             ],
         );
 
@@ -2313,7 +2406,7 @@ fn main() {
 
     fn table(
         source_range: Range<usize>,
-        header: ParsedMarkdownTableRow,
+        header: Vec<ParsedMarkdownTableRow>,
         body: Vec<ParsedMarkdownTableRow>,
     ) -> ParsedMarkdownTable {
         ParsedMarkdownTable {
@@ -2324,8 +2417,22 @@ fn main() {
         }
     }
 
-    fn row(children: Vec<MarkdownParagraph>) -> ParsedMarkdownTableRow {
-        ParsedMarkdownTableRow { children }
+    fn row(columns: Vec<ParsedMarkdownTableColumn>) -> ParsedMarkdownTableRow {
+        ParsedMarkdownTableRow { columns }
+    }
+
+    fn column(
+        col_span: usize,
+        row_span: usize,
+        is_header: bool,
+        children: MarkdownParagraph,
+    ) -> ParsedMarkdownTableColumn {
+        ParsedMarkdownTableColumn {
+            col_span,
+            row_span,
+            is_header,
+            children,
+        }
     }
 
     impl PartialEq for ParsedMarkdownTable {

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -8,8 +8,8 @@ use fs::normalize_path;
 use gpui::{
     AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
     Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
-    Keystroke, Length, Modifiers, ParentElement, Render, Resource, SharedString, Styled,
-    StyledText, TextStyle, WeakEntity, Window, div, img, rems,
+    Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText,
+    TextStyle, WeakEntity, Window, div, img, rems,
 };
 use settings::Settings;
 use std::{
@@ -19,8 +19,10 @@ use std::{
 };
 use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
 use ui::{
-    Clickable, FluentBuilder, LinkPreview, StatefulInteractiveElement, StyledExt, StyledImage,
-    ToggleState, Tooltip, VisibleOnHover, prelude::*, tooltip_container,
+    ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
+    InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, Pixels, Rems,
+    StatefulInteractiveElement, StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover,
+    h_flex, tooltip_container, v_flex,
 };
 use workspace::{OpenOptions, OpenVisible, Workspace};
 
@@ -467,132 +469,100 @@ impl gpui::RenderOnce for MarkdownCheckbox {
     }
 }
 
-fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
-    paragraphs
-        .iter()
-        .map(|paragraph| match paragraph {
-            MarkdownParagraphChunk::Text(text) => text.contents.len(),
-            // TODO: Scale column width based on image size
-            MarkdownParagraphChunk::Image(_) => 1,
-        })
-        .sum()
+fn calculate_table_columns_count(rows: &Vec<ParsedMarkdownTableRow>) -> usize {
+    let mut actual_column_count = 0;
+    for row in rows {
+        actual_column_count = actual_column_count.max(
+            row.columns
+                .iter()
+                .map(|column| column.col_span)
+                .sum::<usize>(),
+        );
+    }
+    actual_column_count
 }
 
 fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
-    let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
+    let actual_header_column_count = calculate_table_columns_count(&parsed.header);
+    let actual_body_column_count = calculate_table_columns_count(&parsed.body);
+    let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count);
 
-    for (index, cell) in parsed.header.children.iter().enumerate() {
-        let length = paragraph_len(cell);
-        max_lengths[index] = length;
-    }
+    let total_rows = parsed.header.len() + parsed.body.len();
 
-    for row in &parsed.body {
-        for (index, cell) in row.children.iter().enumerate() {
-            let length = paragraph_len(cell);
+    // Track which grid cells are occupied by spanning cells
+    let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
 
-            if index >= max_lengths.len() {
-                max_lengths.resize(index + 1, length);
-            }
-
-            if length > max_lengths[index] {
-                max_lengths[index] = length;
-            }
-        }
-    }
+    let mut cells = Vec::with_capacity(total_rows * max_column_count);
 
-    let total_max_length: usize = max_lengths.iter().sum();
-    let max_column_widths: Vec<f32> = max_lengths
-        .iter()
-        .map(|&length| length as f32 / total_max_length as f32)
-        .collect();
+    for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() {
+        let mut col_idx = 0;
 
-    let header = render_markdown_table_row(
-        &parsed.header,
-        &parsed.column_alignments,
-        &max_column_widths,
-        true,
-        0,
-        cx,
-    );
-
-    let body: Vec<AnyElement> = parsed
-        .body
-        .iter()
-        .enumerate()
-        .map(|(index, row)| {
-            render_markdown_table_row(
-                row,
-                &parsed.column_alignments,
-                &max_column_widths,
-                false,
-                index,
-                cx,
-            )
-        })
-        .collect();
+        for (cell_idx, cell) in row.columns.iter().enumerate() {
+            // Skip columns occupied by row-spanning cells from previous rows
+            while col_idx < max_column_count && grid_occupied[row_idx][col_idx] {
+                col_idx += 1;
+            }
 
-    div().child(header).children(body).into_any()
-}
+            if col_idx >= max_column_count {
+                break;
+            }
 
-fn render_markdown_table_row(
-    parsed: &ParsedMarkdownTableRow,
-    alignments: &Vec<ParsedMarkdownTableAlignment>,
-    max_column_widths: &Vec<f32>,
-    is_header: bool,
-    row_index: usize,
-    cx: &mut RenderContext,
-) -> AnyElement {
-    let mut items = Vec::with_capacity(parsed.children.len());
-    let count = parsed.children.len();
+            let alignment = parsed
+                .column_alignments
+                .get(cell_idx)
+                .copied()
+                .unwrap_or_else(|| {
+                    if cell.is_header {
+                        ParsedMarkdownTableAlignment::Center
+                    } else {
+                        ParsedMarkdownTableAlignment::None
+                    }
+                });
 
-    for (index, cell) in parsed.children.iter().enumerate() {
-        let alignment = alignments
-            .get(index)
-            .copied()
-            .unwrap_or(ParsedMarkdownTableAlignment::None);
+            let container = match alignment {
+                ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
+                ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
+                ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
+            };
 
-        let contents = render_markdown_text(cell, cx);
+            let cell_element = container
+                .col_span(cell.col_span.min(max_column_count - col_idx) as u16)
+                .row_span(cell.row_span.min(total_rows - row_idx) as u16)
+                .children(render_markdown_text(&cell.children, cx))
+                .px_2()
+                .py_1()
+                .border_1()
+                .size_full()
+                .border_color(cx.border_color)
+                .when(cell.is_header, |this| {
+                    this.bg(cx.title_bar_background_color)
+                })
+                .when(cell.row_span > 1, |this| this.justify_center())
+                .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
 
-        let container = match alignment {
-            ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
-            ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
-            ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
-        };
+            cells.push(cell_element);
 
-        let max_width = max_column_widths.get(index).unwrap_or(&0.0);
-        let mut cell = container
-            .w(Length::Definite(relative(*max_width)))
-            .h_full()
-            .children(contents)
-            .px_2()
-            .py_1()
-            .border_color(cx.border_color)
-            .border_l_1();
-
-        if count == index + 1 {
-            cell = cell.border_r_1();
-        }
+            // Mark grid positions as occupied for row-spanning cells
+            for r in 0..cell.row_span {
+                for c in 0..cell.col_span {
+                    if row_idx + r < total_rows && col_idx + c < max_column_count {
+                        grid_occupied[row_idx + r][col_idx + c] = true;
+                    }
+                }
+            }
 
-        if is_header {
-            cell = cell.bg(cx.title_bar_background_color).opacity(0.6)
+            col_idx += cell.col_span;
         }
-
-        items.push(cell);
-    }
-
-    let mut row = h_flex().border_color(cx.border_color);
-
-    if is_header {
-        row = row.border_y_1();
-    } else {
-        row = row.border_b_1();
-    }
-
-    if row_index % 2 == 1 {
-        row = row.bg(cx.panel_background_color)
     }
 
-    row.children(items).into_any_element()
+    cx.with_common_p(div())
+        .grid()
+        .size_full()
+        .grid_cols(max_column_count as u16)
+        .border_1()
+        .border_color(cx.border_color)
+        .children(cells)
+        .into_any()
 }
 
 fn render_markdown_block_quote(
@@ -903,3 +873,143 @@ impl Render for InteractiveMarkdownElementTooltip {
         })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::markdown_elements::ParsedMarkdownTableColumn;
+    use crate::markdown_elements::ParsedMarkdownText;
+
+    fn text(text: &str) -> MarkdownParagraphChunk {
+        MarkdownParagraphChunk::Text(ParsedMarkdownText {
+            source_range: 0..text.len(),
+            contents: SharedString::new(text),
+            highlights: Default::default(),
+            region_ranges: Default::default(),
+            regions: Default::default(),
+        })
+    }
+
+    fn column(
+        col_span: usize,
+        row_span: usize,
+        children: Vec<MarkdownParagraphChunk>,
+    ) -> ParsedMarkdownTableColumn {
+        ParsedMarkdownTableColumn {
+            col_span,
+            row_span,
+            is_header: false,
+            children,
+        }
+    }
+
+    fn column_with_row_span(
+        col_span: usize,
+        row_span: usize,
+        children: Vec<MarkdownParagraphChunk>,
+    ) -> ParsedMarkdownTableColumn {
+        ParsedMarkdownTableColumn {
+            col_span,
+            row_span,
+            is_header: false,
+            children,
+        }
+    }
+
+    #[test]
+    fn test_calculate_table_columns_count() {
+        assert_eq!(0, calculate_table_columns_count(&vec![]));
+
+        assert_eq!(
+            1,
+            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
+                column(1, 1, vec![text("column1")])
+            ])])
+        );
+
+        assert_eq!(
+            2,
+            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
+                column(1, 1, vec![text("column1")]),
+                column(1, 1, vec![text("column2")]),
+            ])])
+        );
+
+        assert_eq!(
+            2,
+            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
+                column(2, 1, vec![text("column1")])
+            ])])
+        );
+
+        assert_eq!(
+            3,
+            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
+                column(1, 1, vec![text("column1")]),
+                column(2, 1, vec![text("column2")]),
+            ])])
+        );
+
+        assert_eq!(
+            2,
+            calculate_table_columns_count(&vec![
+                ParsedMarkdownTableRow::with_columns(vec![
+                    column(1, 1, vec![text("column1")]),
+                    column(1, 1, vec![text("column2")]),
+                ]),
+                ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),])
+            ])
+        );
+
+        assert_eq!(
+            3,
+            calculate_table_columns_count(&vec![
+                ParsedMarkdownTableRow::with_columns(vec![
+                    column(1, 1, vec![text("column1")]),
+                    column(1, 1, vec![text("column2")]),
+                ]),
+                ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),])
+            ])
+        );
+    }
+
+    #[test]
+    fn test_row_span_support() {
+        assert_eq!(
+            3,
+            calculate_table_columns_count(&vec![
+                ParsedMarkdownTableRow::with_columns(vec![
+                    column_with_row_span(1, 2, vec![text("spans 2 rows")]),
+                    column(1, 1, vec![text("column2")]),
+                    column(1, 1, vec![text("column3")]),
+                ]),
+                ParsedMarkdownTableRow::with_columns(vec![
+                    // First column is covered by row span from above
+                    column(1, 1, vec![text("column2 row2")]),
+                    column(1, 1, vec![text("column3 row2")]),
+                ])
+            ])
+        );
+
+        assert_eq!(
+            4,
+            calculate_table_columns_count(&vec![
+                ParsedMarkdownTableRow::with_columns(vec![
+                    column_with_row_span(1, 3, vec![text("spans 3 rows")]),
+                    column_with_row_span(2, 1, vec![text("spans 2 cols")]),
+                    column(1, 1, vec![text("column4")]),
+                ]),
+                ParsedMarkdownTableRow::with_columns(vec![
+                    // First column covered by row span
+                    column(1, 1, vec![text("column2")]),
+                    column(1, 1, vec![text("column3")]),
+                    column(1, 1, vec![text("column4")]),
+                ]),
+                ParsedMarkdownTableRow::with_columns(vec![
+                    // First column still covered by row span
+                    column(3, 1, vec![text("spans 3 cols")]),
+                ])
+            ])
+        );
+    }
+}